aizuchi 0.2.0 → 0.4.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 +147 -0
- package/README.md +77 -17
- package/dist/bin/aizuchi.js +120 -2
- package/dist/server/app.js +314 -9
- package/dist/server/board.js +35 -5
- package/dist/server/lib/config-paths.js +60 -19
- package/dist/server/lib/files.js +56 -21
- package/dist/server/lib/proposal.js +10 -0
- package/dist/server/lib/task-ops.js +102 -2
- package/dist/server/lib/trash.js +122 -0
- package/dist/server/watcher.js +17 -4
- package/dist/shared/board-diff.js +48 -0
- package/dist/shared/diff.js +16 -0
- package/dist/shared/schema.js +82 -0
- package/dist/shared/stats.js +117 -1
- package/dist/web/assets/index-BYfu8Hdw.js +93 -0
- package/dist/web/assets/index-DiMXsTSP.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
- package/templates/config.json +2 -1
- package/dist/web/assets/index-C15nwft7.js +0 -82
- package/dist/web/assets/index-qSD20HmA.css +0 -1
package/dist/server/lib/files.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import matter from 'gray-matter';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { resolveConfigPath } from './config-paths.js';
|
|
4
|
+
import { GLOBAL_BROWSE_DIRS, resolveConfigPath, resolveGlobalConfigPath } from './config-paths.js';
|
|
5
5
|
import { hasSafeConfigExt, isSecretPath, PathValidationError, readFileNoFollow, sanitizeConfigDirEntries, writeFileAtomic, } from './fs-safe.js';
|
|
6
6
|
/**
|
|
7
7
|
* AI 設定ファイル管理 (F-4) のファイル I/O。
|
|
@@ -19,26 +19,32 @@ async function relPathFromRealRoot(projectRoot, absPath) {
|
|
|
19
19
|
const rootReal = await fs.realpath(root).catch(() => root);
|
|
20
20
|
return path.relative(rootReal, absPath);
|
|
21
21
|
}
|
|
22
|
+
/** root に正規化済み absPath を読む共通コア (O_NOFOLLOW で検証〜読取間の symlink 差し替えを防ぐ・N-8) */
|
|
23
|
+
async function readResolved(root, absPath) {
|
|
24
|
+
const stat = await fs.stat(absPath);
|
|
25
|
+
const content = await readFileNoFollow(absPath);
|
|
26
|
+
return { path: await relPathFromRealRoot(root, absPath), content, mtimeMs: stat.mtimeMs };
|
|
27
|
+
}
|
|
22
28
|
/** 許可リスト内ファイルを読む (F-4-1 / F-4-6)。許可外は PathValidationError */
|
|
23
29
|
export async function readConfigFile(projectRoot, config, relPath) {
|
|
24
30
|
const { absPath } = await resolveConfigPath(projectRoot, config, relPath);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
return readResolved(projectRoot, absPath);
|
|
32
|
+
}
|
|
33
|
+
/** グローバル (~/.claude/skills,agents) ファイルを読む。同じ検証コアを root=home で通す */
|
|
34
|
+
export async function readGlobalConfigFile(homeDir, relPath) {
|
|
35
|
+
const { absPath } = await resolveGlobalConfigPath(homeDir, relPath);
|
|
36
|
+
return readResolved(homeDir, absPath);
|
|
30
37
|
}
|
|
31
38
|
/**
|
|
32
|
-
*
|
|
33
|
-
* -
|
|
34
|
-
* -
|
|
39
|
+
* root に正規化済み absPath を保存する共通コア。
|
|
40
|
+
* - 親未作成なら作成 (新規ファイル作成の導線)
|
|
41
|
+
* - 既存があれば `.bak` を作成 (上書き前のバックアップ・mode 保全 R8-2)
|
|
42
|
+
* - 本体も mode 保全 (秘匿ファイルの権限が緩まないように、N-8 / Round 1 #7)
|
|
35
43
|
* mtime 不一致は呼び出し側で 409 にする (この関数は検証済み前提で書く)。
|
|
36
44
|
*/
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
// .bak はコピー元 (秘匿ファイル) の mode を継がせる (R8-2: settings.local.json 0o600 の .bak が
|
|
41
|
-
// 0o644 で漏れないように)。preserveMode は .bak の既存 mode を見るため未存在だと効かない → mode を明示
|
|
45
|
+
async function writeResolved(root, absPath, content) {
|
|
46
|
+
// absPath は resolveConfigPath / resolveGlobalConfigPath で検証済み (root 配下・許可リスト内・symlink 非経由)
|
|
47
|
+
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
|
42
48
|
try {
|
|
43
49
|
const prevStat = await fs.stat(absPath);
|
|
44
50
|
// R14-4 再修正 (#5): prev 読取も O_NOFOLLOW に統一 (config 読取経路の TOCTOU を docs と対称に塞ぐ)
|
|
@@ -51,11 +57,20 @@ export async function saveConfigFile(projectRoot, config, relPath, content) {
|
|
|
51
57
|
}
|
|
52
58
|
await writeFileAtomic(absPath, content, { preserveMode: true });
|
|
53
59
|
const stat = await fs.stat(absPath);
|
|
54
|
-
return { path: await relPathFromRealRoot(
|
|
60
|
+
return { path: await relPathFromRealRoot(root, absPath), content, mtimeMs: stat.mtimeMs };
|
|
55
61
|
}
|
|
56
|
-
/**
|
|
57
|
-
export async function
|
|
62
|
+
/** 許可リスト内ファイルを保存する (F-4-1/F-4-3/F-4-6)。許可外は PathValidationError */
|
|
63
|
+
export async function saveConfigFile(projectRoot, config, relPath, content) {
|
|
58
64
|
const { absPath } = await resolveConfigPath(projectRoot, config, relPath);
|
|
65
|
+
return writeResolved(projectRoot, absPath, content);
|
|
66
|
+
}
|
|
67
|
+
/** グローバル (~/.claude/skills,agents) ファイルを保存する。同じ検証コアを root=home で通す */
|
|
68
|
+
export async function saveGlobalConfigFile(homeDir, relPath, content) {
|
|
69
|
+
const { absPath } = await resolveGlobalConfigPath(homeDir, relPath);
|
|
70
|
+
return writeResolved(homeDir, absPath, content);
|
|
71
|
+
}
|
|
72
|
+
/** ファイルの現在 mtime を返す (PUT の競合検知用)。存在しなければ null */
|
|
73
|
+
async function statMtime(absPath) {
|
|
59
74
|
try {
|
|
60
75
|
return (await fs.stat(absPath)).mtimeMs;
|
|
61
76
|
}
|
|
@@ -63,6 +78,15 @@ export async function fileMtime(projectRoot, config, relPath) {
|
|
|
63
78
|
return null;
|
|
64
79
|
}
|
|
65
80
|
}
|
|
81
|
+
export async function fileMtime(projectRoot, config, relPath) {
|
|
82
|
+
const { absPath } = await resolveConfigPath(projectRoot, config, relPath);
|
|
83
|
+
return statMtime(absPath);
|
|
84
|
+
}
|
|
85
|
+
/** グローバルファイルの現在 mtime (PUT 競合検知用)。許可外は PathValidationError */
|
|
86
|
+
export async function globalFileMtime(homeDir, relPath) {
|
|
87
|
+
const { absPath } = await resolveGlobalConfigPath(homeDir, relPath);
|
|
88
|
+
return statMtime(absPath);
|
|
89
|
+
}
|
|
66
90
|
/** browseDirs 配下のファイルツリーを構築する (F-4-2)。各 browseDir をルートにした木の配列 */
|
|
67
91
|
export async function buildFileTree(projectRoot, config) {
|
|
68
92
|
const root = path.resolve(projectRoot);
|
|
@@ -179,17 +203,20 @@ async function collectMarkdownFilesNoSymlink(absDir) {
|
|
|
179
203
|
* .md ファイルのみ対象。frontmatter が無い / パース不能ならそれぞれ null。
|
|
180
204
|
* symlink 中間ディレクトリ・symlink ファイルは辿らない (R8-1: プロジェクト外漏洩を防ぐ)。
|
|
181
205
|
*/
|
|
182
|
-
|
|
183
|
-
const root = path.resolve(
|
|
206
|
+
async function buildCatalogFrom(rootInput, browseDirsRel) {
|
|
207
|
+
const root = path.resolve(rootInput);
|
|
184
208
|
const items = [];
|
|
185
209
|
// R14-1: buildFileTree と同じく root 自身 / 外を指す browseDir を除外する
|
|
186
|
-
for (const absDir of sanitizeConfigDirEntries(root,
|
|
210
|
+
for (const absDir of sanitizeConfigDirEntries(root, [...browseDirsRel])) {
|
|
187
211
|
// R14-1 再修正 (#3): browseDir 自体が .git 等の秘匿パスなら catalog 化しない
|
|
188
212
|
if (isSecretPath(absDir))
|
|
189
213
|
continue;
|
|
190
214
|
for (const abs of await collectMarkdownFilesNoSymlink(absDir)) {
|
|
191
215
|
try {
|
|
192
|
-
|
|
216
|
+
// readConfigFile / readDoc と同じく O_NOFOLLOW 読込に統一し、collectMarkdownFilesNoSymlink の
|
|
217
|
+
// lstat → 読込の間に最終成分が symlink へ差し替わる TOCTOU 窓を塞ぐ (R14-4 #5 の defense-in-depth)。
|
|
218
|
+
// symlink は throw → 下の catch でスキップされる (通常ファイルの挙動は不変)。
|
|
219
|
+
const raw = await readFileNoFollow(abs);
|
|
193
220
|
const data = matter(raw).data;
|
|
194
221
|
items.push({
|
|
195
222
|
path: path.relative(root, abs),
|
|
@@ -205,4 +232,12 @@ export async function buildCatalog(projectRoot, config) {
|
|
|
205
232
|
items.sort((a, b) => a.path.localeCompare(b.path));
|
|
206
233
|
return items;
|
|
207
234
|
}
|
|
235
|
+
/** プロジェクトの skill / agent カタログ (F-4-4)。aiConfigPaths.browseDirs の frontmatter name/description 一覧 */
|
|
236
|
+
export async function buildCatalog(projectRoot, config) {
|
|
237
|
+
return buildCatalogFrom(projectRoot, config.aiConfigPaths.browseDirs);
|
|
238
|
+
}
|
|
239
|
+
/** グローバル (~/.claude/skills,agents) の skill / agent カタログ。同じ走査ロジックを root=home で通す */
|
|
240
|
+
export async function buildGlobalCatalog(homeDir) {
|
|
241
|
+
return buildCatalogFrom(homeDir, GLOBAL_BROWSE_DIRS);
|
|
242
|
+
}
|
|
208
243
|
export { PathValidationError };
|
|
@@ -42,6 +42,16 @@ export function replaceBody(raw, newBody) {
|
|
|
42
42
|
// gray-matter の content は閉じ --- 直後の 1 改行を取り除いた形なので、ここで 1 つ補う
|
|
43
43
|
return `${bom}${header}\n${newBody}`;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* 差し戻し (やり直し依頼) の作業ログ行の本文を組み立てる純関数。
|
|
47
|
+
* 作業ログは行ベース (`- 時刻 [member] <text>`) なので、reason に含まれる改行は半角スペースへ畳み、
|
|
48
|
+
* 前後の空白を除く。reason が空 (未指定 / 空白のみ) なら理由なしの「差し戻し」だけを返す。
|
|
49
|
+
* (reject の `却下: ${reason}` パターンに対応する revise 版。reason は任意なのでこの分岐を持つ)
|
|
50
|
+
*/
|
|
51
|
+
export function reviseWorkLogText(reason) {
|
|
52
|
+
const normalized = (reason ?? '').replace(/[\r\n]+/g, ' ').trim();
|
|
53
|
+
return normalized.length > 0 ? `差し戻し: ${normalized}` : '差し戻し';
|
|
54
|
+
}
|
|
45
55
|
/**
|
|
46
56
|
* タスク本文の `## 作業ログ` セクション末尾に 1 行追記する。
|
|
47
57
|
* - セクションがあれば、その内容ブロックの末尾 (次見出し or EOF 直前) に挿入
|
|
@@ -3,11 +3,12 @@ import path from 'node:path';
|
|
|
3
3
|
import { findMemberViolations, moveInputSchema, newTaskInputSchema, NEW_TASK_BODY, summarizeZodError, taskFrontmatterSchema, taskIdSchema, } from '../../shared/schema.js';
|
|
4
4
|
import { loadConfig, loadTaskView } from '../board.js';
|
|
5
5
|
import { allocateNextId, createSerialQueue } from './id.js';
|
|
6
|
-
import { FileExistsError, safeWriteFile } from './fs-safe.js';
|
|
6
|
+
import { FileExistsError, resolveKanbanReal, safeWriteFile } from './fs-safe.js';
|
|
7
7
|
import { applyPinpointPatch, verifyPinpointResult } from './pinpoint.js';
|
|
8
|
-
import { appendCommentLine, appendWorkLogLine, replaceBody } from './proposal.js';
|
|
8
|
+
import { appendCommentLine, appendWorkLogLine, replaceBody, reviseWorkLogText } from './proposal.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
12
|
/**
|
|
12
13
|
* タスク操作のコアロジック (AI 用 CLI F-6-3 が使う)。
|
|
13
14
|
*
|
|
@@ -18,6 +19,18 @@ import { nowIso, workLogTimestamp } from './time.js';
|
|
|
18
19
|
* エラーは TaskOpError (code 付き) で投げる。N-8: メッセージにファイル本文・設定内容を含めない。
|
|
19
20
|
*/
|
|
20
21
|
const NEW_TASK_STATUS_DEFAULT = 'todo';
|
|
22
|
+
/**
|
|
23
|
+
* 差し戻し (revise) を許可する源 status の集合。proposed / backlog は除外する。
|
|
24
|
+
* proposed→todo を revise で迂回されると approve 承認フロー (proposed→todo) を骨抜きにし恒久
|
|
25
|
+
* mode_violation を生むため、CLI (TaskOps.revise) と HTTP ルート (app.ts) で同じ集合を共有する。
|
|
26
|
+
* 軽量モジュール (HTTP 非依存) であるここを唯一の源にし、app.ts はこれを import して drift を防ぐ。
|
|
27
|
+
*/
|
|
28
|
+
export const REVISABLE_STATUSES = new Set([
|
|
29
|
+
'todo',
|
|
30
|
+
'in_progress',
|
|
31
|
+
'pending',
|
|
32
|
+
'done',
|
|
33
|
+
]);
|
|
21
34
|
export class TaskOpError extends Error {
|
|
22
35
|
code;
|
|
23
36
|
/** zod 由来の検証メッセージ (フィールド名 + 制約。本文は含まない) */
|
|
@@ -43,6 +56,22 @@ export class TaskOps {
|
|
|
43
56
|
taskPath(id) {
|
|
44
57
|
return path.join(this.tasksDir, `${id}.md`);
|
|
45
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* 破壊的書込 (move / revise) の直前に現内容を `.kanban/.trash/<id>.<epoch_ms>.md` へコピー退避する
|
|
61
|
+
* 安全網 (ごみ箱)。HTTP ルート (app.ts) の snapshot ヘルパーと同型・同 trashKeep。
|
|
62
|
+
* ベストエフォート (退避失敗は本処理を止めない)。退避は元を消さない「コピー」。
|
|
63
|
+
*/
|
|
64
|
+
async snapshot(id, currentContent) {
|
|
65
|
+
try {
|
|
66
|
+
const kanbanReal = await resolveKanbanReal(this.projectRoot);
|
|
67
|
+
const { config } = await loadConfig(this.projectRoot);
|
|
68
|
+
const keep = config.trashKeep ?? DEFAULT_TRASH_KEEP;
|
|
69
|
+
await snapshotToTrash(id, currentContent, this.kanbanDir, kanbanReal, keep);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// 退避はベストエフォート安全網。失敗しても本処理 (破壊的書込) は止めない
|
|
73
|
+
}
|
|
74
|
+
}
|
|
46
75
|
/** 起票 (採番直列化・schema 検証・members 整合)。POST /api/tasks と同じロジック */
|
|
47
76
|
async create(input) {
|
|
48
77
|
// CLI は起票時の status を任意指定できる (HTTP POST は todo 固定だが、AI が backlog/proposed 等で
|
|
@@ -149,6 +178,7 @@ export class TaskOps {
|
|
|
149
178
|
if (!check.success) {
|
|
150
179
|
throw new TaskOpError('invalid_input', 'ステータス変更後のタスクが不正', summarizeZodError(check.error));
|
|
151
180
|
}
|
|
181
|
+
await this.snapshot(id, raw); // 旧版を .trash へ退避 (ベストエフォート)
|
|
152
182
|
await safeWriteFile(filePath, patched, [this.kanbanDir]);
|
|
153
183
|
return loadTaskView(this.projectRoot, filePath);
|
|
154
184
|
});
|
|
@@ -157,6 +187,73 @@ export class TaskOps {
|
|
|
157
187
|
async appendLog(id, member, message) {
|
|
158
188
|
return this.appendToSection(id, member, message, 'log');
|
|
159
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* やり直し依頼 (差し戻し)。todo/in_progress/pending/done → todo へ戻し、理由を作業ログに残す。
|
|
192
|
+
* HTTP の POST /api/tasks/:id/revise と同型の単一書込でアトミックに行う:
|
|
193
|
+
* read → status を todo へ pinpoint (+ pending なら blocked_* を null へ) + 差し戻し理由を
|
|
194
|
+
* 作業ログへ追記 → 1 回の safeWriteFile。
|
|
195
|
+
* 旧実装は move(todo) + appendLog の 2 段書込 (別 enqueue) で、move 成功後 appendLog 失敗時に
|
|
196
|
+
* 「理由を失った差し戻し」部分状態を生みえた。HTTP 版と結果整合させ非アトミック性を解消する。
|
|
197
|
+
* proposed / backlog からの差し戻しは approve 承認フロー迂回を防ぐため conflict で弾く (app.ts と対称)。
|
|
198
|
+
*/
|
|
199
|
+
async revise(id, input = {}) {
|
|
200
|
+
if (!taskIdSchema.safeParse(id).success) {
|
|
201
|
+
throw new TaskOpError('invalid_input', '不正なタスク ID');
|
|
202
|
+
}
|
|
203
|
+
const filePath = this.taskPath(id);
|
|
204
|
+
return this.enqueue(async () => {
|
|
205
|
+
let raw;
|
|
206
|
+
try {
|
|
207
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
throw new TaskOpError('not_found', `タスクが見つからない: ${id}`);
|
|
211
|
+
}
|
|
212
|
+
let current;
|
|
213
|
+
try {
|
|
214
|
+
current = parseTaskFile(raw);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
const message = error instanceof TaskFileParseError ? error.message : 'タスクファイルを解析できない';
|
|
218
|
+
throw new TaskOpError('conflict', `差し戻しできない (${message})`);
|
|
219
|
+
}
|
|
220
|
+
// 源 status ガード: proposed / backlog からの差し戻しは承認フロー迂回になるため弾く (app.ts と対称)
|
|
221
|
+
if (!REVISABLE_STATUSES.has(current.data.status)) {
|
|
222
|
+
throw new TaskOpError('conflict', 'この状態のタスクは差し戻せません');
|
|
223
|
+
}
|
|
224
|
+
// status を todo へ pinpoint (本文・他フィールドは保全)。pending から戻す場合は blocked_* も null へ
|
|
225
|
+
const patch = { status: 'todo', updated: nowIso() };
|
|
226
|
+
if (current.data.blocked_type != null || current.data.blocked_reason != null) {
|
|
227
|
+
patch.blocked_type = null;
|
|
228
|
+
patch.blocked_reason = null;
|
|
229
|
+
}
|
|
230
|
+
let patched;
|
|
231
|
+
let patchedParsed;
|
|
232
|
+
try {
|
|
233
|
+
patched = applyPinpointPatch(raw, patch);
|
|
234
|
+
patchedParsed = parseTaskFile(patched);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
const message = error instanceof Error ? error.message : '差し戻しに失敗';
|
|
238
|
+
throw new TaskOpError('conflict', `差し戻しできない (${message})`);
|
|
239
|
+
}
|
|
240
|
+
const drift = verifyPinpointResult(current, patchedParsed, patch);
|
|
241
|
+
if (drift !== null) {
|
|
242
|
+
throw new TaskOpError('conflict', `差し戻しできない (${drift})`);
|
|
243
|
+
}
|
|
244
|
+
const check = taskFrontmatterSchema.safeParse(patchedParsed.data);
|
|
245
|
+
if (!check.success) {
|
|
246
|
+
throw new TaskOpError('invalid_input', '差し戻し後のタスクが不正', summarizeZodError(check.error));
|
|
247
|
+
}
|
|
248
|
+
// 差し戻し理由を作業ログへ追記 (frontmatter は pinpoint 済みなので body だけ更新する)。
|
|
249
|
+
// status 変更と理由ログを 1 回の safeWriteFile で書く = アトミック (HTTP 版と同型)
|
|
250
|
+
const logLine = `${workLogTimestamp()} [human] ${reviseWorkLogText(input.reason)}`;
|
|
251
|
+
const finalRaw = replaceBody(patched, appendWorkLogLine(patchedParsed.body, logLine));
|
|
252
|
+
await this.snapshot(id, raw); // 旧版を .trash へ退避 (ベストエフォート)
|
|
253
|
+
await safeWriteFile(filePath, finalRaw, [this.kanbanDir]);
|
|
254
|
+
return loadTaskView(this.projectRoot, filePath);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
160
257
|
/** やりとり追記 (## やりとり に 1 行)。member は config.members 検証 */
|
|
161
258
|
async appendComment(id, member, message) {
|
|
162
259
|
const { config } = await loadConfig(this.projectRoot);
|
|
@@ -199,6 +296,9 @@ export class TaskOps {
|
|
|
199
296
|
}
|
|
200
297
|
const line = `${workLogTimestamp()} [${member}] ${message}`;
|
|
201
298
|
const newBody = section === 'log' ? appendWorkLogLine(current.body, line) : appendCommentLine(current.body, line);
|
|
299
|
+
// SEC-N3: 追記も read→全文上書きのため、直前にディスク現内容を .trash へ退避する (move/revise と同様)。
|
|
300
|
+
// 外部編集と交錯して上書きで失われても復元できるようにする (ベストエフォート)。
|
|
301
|
+
await this.snapshot(id, raw);
|
|
202
302
|
await safeWriteFile(filePath, replaceBody(raw, newBody), [this.kanbanDir]);
|
|
203
303
|
return loadTaskView(this.projectRoot, filePath);
|
|
204
304
|
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { collectKanbanFilesNoSymlink, FileExistsError, safeWriteFile } from './fs-safe.js';
|
|
4
|
+
/**
|
|
5
|
+
* ごみ箱 (`.kanban/.trash/`): タスク本体を破壊的に書き換える前に旧版を `.trash/<id>.<epoch_ms>.md` へ
|
|
6
|
+
* コピー退避し、誤上書き / 誤操作からワンクリック復元できる安全網 (git commit していない限り
|
|
7
|
+
* 取り戻せない問題を塞ぐ。`.bak` は F-4 設定ファイル管理のみでタスクは無防備だった)。
|
|
8
|
+
*
|
|
9
|
+
* 設計の核:
|
|
10
|
+
* - **コピー退避 (rename でなく書込)**: 退避が失敗しても原本を失わない (ベストエフォート安全網)。
|
|
11
|
+
* - **N-2 厳守**: 退避先 (`.trash`) も `safeWriteFile([kanbanDir])` 経由で許可リスト封じ込め + symlink 非追従。
|
|
12
|
+
* 退避一覧・復元時の走査も `collectKanbanFilesNoSymlink` (realpath containment + symlink 非追従) を使う。
|
|
13
|
+
* - **世代の刈り取り**: 同 id の退避が trashKeep 件を超えたら古い順 (epoch 昇順) に削除する。
|
|
14
|
+
*
|
|
15
|
+
* `<epoch_ms>` は退避時刻のエポックミリ秒 (Date.now())。世代の新旧判定とファイル名の一意化を兼ねる。
|
|
16
|
+
*/
|
|
17
|
+
/** ごみ箱ディレクトリ名 (1 箇所集約) */
|
|
18
|
+
export const TRASH_DIR = '.trash';
|
|
19
|
+
/** 同 id あたりの退避世代の既定保持件数 (config.trashKeep で上書き可・コード 1 箇所集約) */
|
|
20
|
+
export const DEFAULT_TRASH_KEEP = 20;
|
|
21
|
+
/**
|
|
22
|
+
* 退避ファイル名 `<id>.<epoch_ms>.md` を {id, trashedAt} に分解する。
|
|
23
|
+
* 形式に合致しない (epoch が数字でない / .md でない) ファイルは null を返す (走査時に無視する)。
|
|
24
|
+
* id 自体に `.` を含みうる形式ではないが、保険として「末尾の <数字>.md」を epoch とみなし、
|
|
25
|
+
* その手前全てを id とする (id は `<prefix>-<digits>` なので `.` を含まない前提だが robust に切り出す)。
|
|
26
|
+
*/
|
|
27
|
+
export function parseTrashName(basename) {
|
|
28
|
+
if (!basename.endsWith('.md'))
|
|
29
|
+
return null;
|
|
30
|
+
const stem = basename.slice(0, -3); // ".md" を外す
|
|
31
|
+
const lastDot = stem.lastIndexOf('.');
|
|
32
|
+
if (lastDot <= 0 || lastDot >= stem.length - 1)
|
|
33
|
+
return null; // 区切りが無い / 端にある
|
|
34
|
+
const id = stem.slice(0, lastDot);
|
|
35
|
+
const epochStr = stem.slice(lastDot + 1);
|
|
36
|
+
if (!/^\d+$/.test(epochStr))
|
|
37
|
+
return null;
|
|
38
|
+
const trashedAt = Number(epochStr);
|
|
39
|
+
if (!Number.isFinite(trashedAt))
|
|
40
|
+
return null;
|
|
41
|
+
return { id, trashedAt };
|
|
42
|
+
}
|
|
43
|
+
/** 退避ファイル名を組み立てる (`<id>.<epoch_ms>.md`) */
|
|
44
|
+
function trashName(id, trashedAt) {
|
|
45
|
+
return `${id}.${trashedAt}.md`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `.trash/` 配下の退避ファイルを {id, trashedAt, absPath} の配列で列挙する (N-2 適用)。
|
|
49
|
+
* `collectKanbanFilesNoSymlink` で realpath containment + symlink 非追従に走査し、形式に合致する
|
|
50
|
+
* ファイルだけを返す。`.trash` 不在 / 空なら空配列。id 指定があればその id の世代だけに絞る。
|
|
51
|
+
*/
|
|
52
|
+
export async function listTrash(kanbanDir, kanbanReal, filterId) {
|
|
53
|
+
const trashRoot = path.join(kanbanDir, TRASH_DIR);
|
|
54
|
+
const files = await collectKanbanFilesNoSymlink(trashRoot, kanbanReal);
|
|
55
|
+
const entries = [];
|
|
56
|
+
for (const abs of files) {
|
|
57
|
+
const parsed = parseTrashName(path.basename(abs));
|
|
58
|
+
if (parsed === null)
|
|
59
|
+
continue;
|
|
60
|
+
if (filterId !== undefined && parsed.id !== filterId)
|
|
61
|
+
continue;
|
|
62
|
+
entries.push({ id: parsed.id, trashedAt: parsed.trashedAt, absPath: abs });
|
|
63
|
+
}
|
|
64
|
+
return entries;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* タスクの現内容を `.trash/<id>.<epoch_ms>.md` へコピー退避する (破壊的書込の直前に呼ぶ)。
|
|
68
|
+
* - 退避は **コピー** (`safeWriteFile`)。rename でないので退避失敗で原本を失わない。
|
|
69
|
+
* - N-2: 書込は `safeWriteFile([kanbanDir])` (許可リスト封じ込め + symlink 非追従)。`.trash` は mkdir で用意する。
|
|
70
|
+
* - 退避後、同 id の世代が trashKeep を超えていれば古い順 (epoch 昇順) に削除する。
|
|
71
|
+
*
|
|
72
|
+
* 失敗時は呼び出し側 (try/catch) で握りつぶし本処理を止めない想定 (ベストエフォート安全網)。
|
|
73
|
+
* 退避できた絶対パスを返す。
|
|
74
|
+
*/
|
|
75
|
+
export async function snapshotToTrash(id, currentContent, kanbanDir, kanbanReal, trashKeep = DEFAULT_TRASH_KEEP) {
|
|
76
|
+
const trashRoot = path.join(kanbanDir, TRASH_DIR);
|
|
77
|
+
await fs.mkdir(trashRoot, { recursive: true });
|
|
78
|
+
// 同一ミリ秒・同 id で連続退避すると epoch_ms が衝突する。exclusive 書込が EEXIST (FileExistsError) なら
|
|
79
|
+
// epoch を 1 ずつ進めて空きを探す (退避は消失防止が目的なので既存世代を上書きしない)。
|
|
80
|
+
const base = Date.now();
|
|
81
|
+
let written = null;
|
|
82
|
+
for (let offset = 0; offset < 1000; offset++) {
|
|
83
|
+
const candidate = base + offset;
|
|
84
|
+
try {
|
|
85
|
+
written = await safeWriteFile(path.join(trashRoot, trashName(id, candidate)), currentContent, [kanbanDir], {
|
|
86
|
+
exclusive: true,
|
|
87
|
+
});
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error instanceof FileExistsError)
|
|
92
|
+
continue; // 衝突 → 次の epoch を試す
|
|
93
|
+
throw error; // それ以外 (containment 違反等) は呼び出し側 (try/catch) へ
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (written === null) {
|
|
97
|
+
// 1000 連続衝突は現実的に起きないが、保険として通常書込で 1 回だけ救済する
|
|
98
|
+
written = await safeWriteFile(path.join(trashRoot, trashName(id, base + Date.now())), currentContent, [kanbanDir]);
|
|
99
|
+
}
|
|
100
|
+
await pruneTrash(id, kanbanDir, kanbanReal, trashKeep);
|
|
101
|
+
return written;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 同 id の退避世代が trashKeep を超えていれば古い順 (epoch 昇順) に削除する。
|
|
105
|
+
* trashKeep 以下なら何もしない。削除失敗 (残骸) はスキップして続行する (ベストエフォート)。
|
|
106
|
+
*/
|
|
107
|
+
export async function pruneTrash(id, kanbanDir, kanbanReal, trashKeep = DEFAULT_TRASH_KEEP) {
|
|
108
|
+
if (!Number.isFinite(trashKeep) || trashKeep < 1)
|
|
109
|
+
return; // 不正値は刈り取りしない (退避を消さない安全側)
|
|
110
|
+
const entries = (await listTrash(kanbanDir, kanbanReal, id)).sort((a, b) => a.trashedAt - b.trashedAt);
|
|
111
|
+
const excess = entries.length - trashKeep;
|
|
112
|
+
if (excess <= 0)
|
|
113
|
+
return;
|
|
114
|
+
for (const entry of entries.slice(0, excess)) {
|
|
115
|
+
try {
|
|
116
|
+
await fs.rm(entry.absPath, { force: true });
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// 消せない残骸はスキップ
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dist/server/watcher.js
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { watch } from 'chokidar';
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* 監視対象: タスクファイル (.md) と config.json のみ。アトミック書込の .tmp は無視する。
|
|
5
|
+
* watch ルート (.kanban) から見てドット始まりのディレクトリ配下 (例: ごみ箱 `.trash/<id>.<epoch>.md`)
|
|
6
|
+
* も無視する。退避コピーや世代刈り取りの unlink で SSE が誤発火し全量再取得が無駄に走るのを防ぐ
|
|
7
|
+
* (将来の内部 dir も同様に除外)。watch ルート自身が `.kanban` のため、ルート相対で判定する
|
|
8
|
+
* (絶対パスをそのまま見るとルートの `.kanban` 成分に引っかかり全ファイルが除外される)。
|
|
9
|
+
*/
|
|
10
|
+
export function isWatchTarget(rootDir, filePath) {
|
|
5
11
|
const base = path.basename(filePath);
|
|
6
|
-
|
|
12
|
+
if (!(base.endsWith('.md') || base === 'config.json'))
|
|
13
|
+
return false;
|
|
14
|
+
// ルート相対パスの中間ディレクトリ成分にドット始まりがあれば内部用とみなし除外する
|
|
15
|
+
const rel = path.relative(rootDir, filePath);
|
|
16
|
+
const dirSegments = rel.split(/[\\/]+/).slice(0, -1);
|
|
17
|
+
if (dirSegments.some((seg) => seg.startsWith('.')))
|
|
18
|
+
return false;
|
|
19
|
+
return true;
|
|
7
20
|
}
|
|
8
21
|
export function startWatcher(options) {
|
|
9
22
|
const { kanbanDir, bus } = options;
|
|
@@ -11,7 +24,7 @@ export function startWatcher(options) {
|
|
|
11
24
|
const watcher = watch(kanbanDir, { ignoreInitial: true });
|
|
12
25
|
let timer = null;
|
|
13
26
|
const onFsEvent = (filePath) => {
|
|
14
|
-
if (!isWatchTarget(filePath))
|
|
27
|
+
if (!isWatchTarget(kanbanDir, filePath))
|
|
15
28
|
return;
|
|
16
29
|
if (timer !== null)
|
|
17
30
|
clearTimeout(timer);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ボード差分 (新着インジケータ用) の純関数。
|
|
3
|
+
* SSE で再取得した前後のタスク一覧を比べ、「人間が見ていない間に変わったタスク」を割り出す。
|
|
4
|
+
* AI → 人間の「何が変わったか」を画面内シグナルとして太くするための補助情報で、
|
|
5
|
+
* これ自体は正本性を持たない (既読マーカーは localStorage に置く・origin 分断は許容)。
|
|
6
|
+
*
|
|
7
|
+
* 純粋・副作用なし。server/web の双方が型を共有できるよう新データ源・新依存はゼロ。
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* 前回スナップショット (prev) と最新 (next) を比べ、変化したタスクの id 集合を返す。
|
|
11
|
+
* - 新規 (prev に無い id) → newIds
|
|
12
|
+
* - updated が進んだ or status が変わった → updatedIds
|
|
13
|
+
* - 削除 (next に無い id) は無視する (消えたカードはハイライト対象にならない)
|
|
14
|
+
*
|
|
15
|
+
* updated は文字列比較ではなく時刻比較で「進んだ」を判定する (ISO 8601 の桁揃えに依存しない)。
|
|
16
|
+
* parse 不能な updated は文字列の不一致をもって変化とみなす (誤検知より取りこぼし回避を優先)。
|
|
17
|
+
*/
|
|
18
|
+
export function diffBoards(prev, next) {
|
|
19
|
+
const prevById = new Map();
|
|
20
|
+
for (const task of prev) {
|
|
21
|
+
prevById.set(task.id, task);
|
|
22
|
+
}
|
|
23
|
+
const newIds = [];
|
|
24
|
+
const updatedIds = [];
|
|
25
|
+
for (const task of next) {
|
|
26
|
+
const before = prevById.get(task.id);
|
|
27
|
+
if (before === undefined) {
|
|
28
|
+
newIds.push(task.id);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (task.status !== before.status || isNewer(task.updated, before.updated)) {
|
|
32
|
+
updatedIds.push(task.id);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { newIds, updatedIds, changedIds: new Set([...newIds, ...updatedIds]) };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* next の updated が prev の updated より「新しい」かを判定する。
|
|
39
|
+
* 両方 parse できれば時刻で比較。どちらかが parse 不能なら文字列の不一致を変化とみなす。
|
|
40
|
+
*/
|
|
41
|
+
function isNewer(nextUpdated, prevUpdated) {
|
|
42
|
+
const nextMs = Date.parse(nextUpdated);
|
|
43
|
+
const prevMs = Date.parse(prevUpdated);
|
|
44
|
+
if (Number.isNaN(nextMs) || Number.isNaN(prevMs)) {
|
|
45
|
+
return nextUpdated !== prevUpdated;
|
|
46
|
+
}
|
|
47
|
+
return nextMs > prevMs;
|
|
48
|
+
}
|
package/dist/shared/diff.js
CHANGED
|
@@ -3,15 +3,31 @@
|
|
|
3
3
|
* PUT 競合 (409) 時にローカル編集とサーバー側内容の差分を提示するために使う。
|
|
4
4
|
* 依存を追加せず ~50 行で実装する。server / web が共有する。
|
|
5
5
|
*/
|
|
6
|
+
/**
|
|
7
|
+
* LCS の DP 行列サイズ (aLines.length × bLines.length) の上限。これを超えたら DP を回さず
|
|
8
|
+
* フォールバックする (長大な作業ログを持つタスクで行 LCS が O(n×m) で重くなるのを避ける)。
|
|
9
|
+
*/
|
|
10
|
+
export const DIFF_MAX_MATRIX = 250000;
|
|
6
11
|
/**
|
|
7
12
|
* 2 つのテキストを行単位で比較し、変更箇所を返す。
|
|
8
13
|
* `equal` = 両方にある行、`remove` = a のみ (削除)、`add` = b のみ (追加)。
|
|
14
|
+
* 行数の積が DIFF_MAX_MATRIX を超える場合は DP を回さず、全体を「a 全削除 + b 全追加」の
|
|
15
|
+
* 1 つの変更ブロックとして返す (行単位の対応付けは諦める)。両 diff 利用箇所が恩恵を受ける。
|
|
9
16
|
*/
|
|
10
17
|
export function diffLines(a, b) {
|
|
11
18
|
const aLines = a.split('\n');
|
|
12
19
|
const bLines = b.split('\n');
|
|
13
20
|
const n = aLines.length;
|
|
14
21
|
const m = bLines.length;
|
|
22
|
+
// サイズガード: DP 行列が大きすぎる場合は LCS を回さず粗いフォールバックを返す
|
|
23
|
+
if (n * m > DIFF_MAX_MATRIX) {
|
|
24
|
+
const result = [];
|
|
25
|
+
for (const text of aLines)
|
|
26
|
+
result.push({ op: 'remove', text });
|
|
27
|
+
for (const text of bLines)
|
|
28
|
+
result.push({ op: 'add', text });
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
15
31
|
// LCS の長さ表 (動的計画法)。lcs[i][j] = aLines[i..], bLines[j..] の LCS 長
|
|
16
32
|
const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
17
33
|
for (let i = n - 1; i >= 0; i--) {
|