diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b818930 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +# CI — PR/push 的最低验证门槛(docs/release.md §4-1/2)。 +# 只跑纯 Node 部分(vitest + tsc + vite build);Tauri/NSIS 构建依赖本机 GNU 工具链, +# 仍走手工 release gate(docs/release.md §0/§3)。 +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test-and-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test + - run: npm run build diff --git a/AGENTS.md b/AGENTS.md index 26b4a9b..a29b349 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,10 @@ ResearchThread 是一个**本地优先**的桌面工具(Tauri 2,macOS + Wind - 阶段:**M3 完成 + 种子发布加固**(M1/M2/M3 全部落地,实机验证)——发布前审阅确认的九项门槛已全部代码落地: - **事务式写入**(`storage/atomicWrite.ts`):写 `.rt-tmp` → 原文件让位 `.rt-bak` → 新内容就位 → 清备份;任何一步失败或进程中断后,**原文件或备份必有一个可恢复**;启动时 `recoverInterruptedWrites()` 扫描恢复并记账。测试注入 rename 失败/持续故障/崩溃残留全覆盖 - **单实例**(`tauri-plugin-single-instance`):第二实例启动即聚焦已有主窗口后退出,杜绝双 watcher + 同名 tmp/bak 互踩 - - **外部编辑并发策略**:storage 保存前比对磁盘内容与应用最后已知内容(`seen` 缓存,读/写后更新),不一致抛 `ExternalConflictError`,UI 弹「覆盖保存(acknowledge 后重试)/ 重新加载 / 取消」。`updateTaskMeta` / `updateEntry` / `updateProjectDescription` / `saveWeekly` 四路受保护;appendEntry 纯追加不设卡 + - **外部编辑并发策略**:storage 保存前比对磁盘内容与应用最后已知内容(`seen` 缓存,读/写后更新),不一致抛 `ExternalConflictError`,UI 弹「覆盖保存(acknowledge 后重试)/ 重新加载 / 取消」。`updateTaskMeta` / `appendEntry` / `updateEntry` / `updateProjectDescription` / `saveWeekly` **五路全部受保护**(appendEntry 同样检查冲突后整文件重写,比纯追加更安全) + - **软删除**:`deleteTask` / `deleteInboxItem` 移入数据目录 `.trash/`(时间戳前缀),不物理删除;恢复 = 移回原位或 git 找回(事务写保护不了 delete,这是当天未快照文件的唯一防线) + - **renderer 安全边界**:生产线 CSP 已开启(`app.security.csp`,`default-src 'self'` 起步);`shell` capability(cmd/powershell/node/npm/claude/codex/git,args:true)是高权限边界,任何新 HTML 渲染入口必须 escape-first(`src/lib/markdown.ts` 原则)——详见 docs/release.md §5-0 + - **CI 门槛**:`.github/workflows/ci.yml`(npm ci → test → build)是 PR 的 merge gate;Tauri/NSIS 仍手工构建(GNU 工具链,见下) - **每日快照健康**(`storage/snapshot.ts` `ensureSnapshotHealth()`):git 可用性检测 → 仓库级身份兜底(`--local`,不动全局配置)→ 补有效提交(修旧版 init 静默失败留下的空仓)→ 每日首启自动提交;任何失败 `ok:false`,主界面左下持久警告横幅 + 设置→数据「重试」 - **草稿与覆盖保护**:`editorState` 增 weekly 脏标记,脏编辑器存在时 `onCloseRequested` / `beforeunload` 拦截退出并确认;周文档重新生成与外部冲突覆盖均需二次点击确认(3 秒 armed) - **周报正确性**:任务定位主键改完整相对路径(修跨项目同名任务归错),活跃工作线 ☆ 置顶稳定排序 @@ -67,6 +70,7 @@ ResearchThread 是一个**本地优先**的桌面工具(Tauri 2,macOS + Wind ├── .gitignore # 至少忽略 .mcp.json ├── .activity/log.jsonl ├── inbox/ # 快速捕获收件箱(未来的自然语言命令队列) +├── .trash/ # 软删除暂存(删除不物理清除;用户手动恢复或清理) ├── spaces/ │ ├── research/ # 空间(≈ 手写页的分类) │ │ └── projects/ diff --git a/README.md b/README.md index 96b503f..72d9027 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ResearchThread -本地优先的科研/工作流程管理桌面应用(Tauri 2,macOS + Windows)。把手写「思路整理」工作流数字化:**空间 → 项目 → 任务**三级层级 + 双层上下文(项目整体上下文 / 任务上下文)+ 周文档。 +本地优先的科研/工作流程管理桌面应用(Tauri 2)。当前种子构建:**Windows x64**;macOS 为计划目标、尚未构建验证。把手写「思路整理」工作流数字化:**空间 → 项目 → 任务**三级层级 + 双层上下文(项目整体上下文 / 任务上下文)+ 周文档。 第二步(已规划、未开始):**人只说自然语言,agent 维护整个项目地图**——口述新任务/新项目/任务转折即可,agent 经 MCP 工具接口执行并定时产出日报/周报。 @@ -21,12 +21,13 @@ ## 技术栈 -- React 19 + Vite + Tailwind v4(SPA;2026-09-10 采纳外部按 UI.md 交付的 UI,剥离其 SSR/登录/数据库) -- Tauri 2(待接入,起步零自定义 Rust) -- 所有数据访问统一走 `src/dataAccess.ts` 接口([UI.md](UI.md) 第 2 节契约);当前为 mock 实现,M1 换接 storage 层 -- 数据层 `src/storage/`(待建):纯核心(core,无 Tauri API)+ IO 适配器(fsAdapter,plugin-fs)拆分——将来同一核心配 `node:fs` 即成 MCP server;`vitest` 单测守护写回契约(宽松读/规范写/保留未知字段) -- 终端面板(M2):`tauri-plugin-shell` + `@xterm/xterm`,经 AgentRunner 接口抽象(第二步对话面板与终端共用后端) -- 数据:Markdown 文件树 + YAML frontmatter,数据目录即 agent 工作区(`AGENTS.md` / `.mcp.json` / `settings.yaml`),可用 Obsidian / git 直接操作 +- React 19 + Vite + Tailwind v4(SPA;按 [UI.md](UI.md) 契约集成外部交付的 UI,剥离其 SSR/登录/数据库) +- Tauri 2 桌面壳(plugin-fs / plugin-shell / single-instance / global-shortcut;生产线启用 CSP,见 [docs/release.md](docs/release.md) §5) +- 数据访问统一走 `src/dataAccess.ts` 接口([UI.md](UI.md) 第 2 节契约):浏览器 dev → MockDataAccess;Tauri 桌面 → StorageDataAccess(Markdown 文件树落盘) +- 数据层 `src/storage/`:纯核心 `core.ts`(无 Tauri API)+ IO 适配器 `fsAdapter.ts`(plugin-fs)+ 事务式写入 `atomicWrite.ts` + 每日快照健康 `snapshot.ts`;同一核心配 `node:fs` 即成 2b 阶段 MCP server(`tests/storageNode.test.ts` 的适配器即雏形) +- Vitest 守护写回契约与数据安全(宽松读/规范写/保留未知字段/外部冲突防护/软删除/中断恢复);CI 见 `.github/workflows/ci.yml` +- 终端面板:`tauri-plugin-shell` + `@xterm/xterm`,经 AgentRunner 接口抽象(第二步对话面板与终端共用后端) +- 数据:Markdown 文件树 + YAML frontmatter,数据目录即 agent 工作区(`AGENTS.md` / `.mcp.json` / `settings.yaml`),可用 Obsidian / git 直接操作;删除进 `.trash/` 可恢复 UI 布局:左侧栏(空间/项目树)|中间展示区|底部可开关面板(第一阶段内置终端,第二步 agent 对话)|右侧可开关侧边栏(详情/上下文编辑)。 diff --git a/docs/release.md b/docs/release.md index 983916f..056c0ea 100644 --- a/docs/release.md +++ b/docs/release.md @@ -61,14 +61,20 @@ npm run make-release # ④ 归拢 release/(含门槛自检) ## 5. 数据安全红线(任何版本不得回退,回退 = 事故) +0. **renderer 安全边界**:生产线 CSP 必须开启(`tauri.conf.json` `app.security.csp`,当前 `default-src 'self'` + 按需放开),**禁止改回 `null`**;`shell` capability(cmd/powershell/node/npm/claude/codex/git,`args: true`)是**高权限边界**——任何新的 HTML 渲染入口(markdown 预览、agent 输出、CLI 输出渲染)必须先 HTML 转义再生成白名单标签(参照 `src/lib/markdown.ts` 的 escape-first 原则),改动必须过安全审查。理由:任何 renderer 注入 + shell 白名单 = 本机代码执行。**2a/2b 接入 agent 时必须重新收紧这份 allowlist**( Roadmap Issue #2)。 1. **写入只走事务**:所有文件写入经 `src/storage/atomicWrite.ts`(tmp → bak 让位 → 就位),保证任何失败/中断后原文件或备份必有一个可恢复;不许绕过它直接调 fs 写。 -2. **外部冲突守卫**:`updateTaskMeta` / `updateEntry` / `updateProjectDescription` / `saveWeekly` 保存前的外部修改检查(`ExternalConflictError` → 用户选择)不许移除或改成静默合并。 +2. **外部冲突守卫**:`updateTaskMeta` / `appendEntry` / `updateEntry` / `updateProjectDescription` / `saveWeekly` 保存前的外部修改检查(`ExternalConflictError` → 用户选择)不许移除或改成静默合并。 3. **单实例**:`tauri-plugin-single-instance` 不许移除。 4. **快照失败必须可见**:git 缺失/提交失败 → 界面持久警告,不许回到静默降级。 5. **脏草稿退出保护**:`onCloseRequested` / `beforeunload` 拦截不许移除。 -6. **数据格式向后兼容**:文件树结构、frontmatter 契约、`status` 五态枚举是存量用户资产——宽松读 / 保留未知字段的写回契约(PLAN.md 第三节)不许破坏;确需破坏性变更:先用户确认、写迁移与回滚说明、升 minor。 -7. **卸载不删数据目录**:NSIS 卸载只删程序本体与应用界面设置;不得增加「删除数据」的卸载选项。 -8. **UI 不触文件**:所有数据访问仍必须经 `src/dataAccess.ts` 边界。 +6. **删除 = 软删除**:`deleteTask` / `deleteInboxItem` 只移入 `.trash/`(时间戳前缀,不覆盖、不清理),不做物理删除——事务写保护不了 delete,这是当天新文件尚未进 git 快照时唯一的丢失防线;恢复 = 从 `.trash/` 移回或 git 找回。 +7. **数据格式向后兼容**:文件树结构、frontmatter 契约、`status` 五态枚举是存量用户资产——宽松读 / 保留未知字段的写回契约(PLAN.md 第三节)不许破坏;确需破坏性变更:先用户确认、写迁移与回滚说明、升 minor。 +8. **卸载不删数据目录**:NSIS 卸载只删程序本体与应用界面设置;不得增加「删除数据」的卸载选项。 +9. **UI 不触文件**:所有数据访问仍必须经 `src/dataAccess.ts` 边界。 + +## 5b. CI 门槛 + +`.github/workflows/ci.yml` 在每个 PR / push 上强制 `npm ci → npm test → npm run build`——**merge gate 是 CI 绿,不是"本地跑过"的口头证据**。Tauri/NSIS 构建依赖本机 GNU 工具链,仍走 §4 手工 release gate;将来环境标准化后可加入 CI。 ## 6. 隐私红线(零遥测,任何版本不得突破) @@ -86,4 +92,5 @@ npm run make-release # ④ 归拢 release/(含门槛自检) | 版本 | 日期 | 构建提交 | 安装包 SHA-256 | 备注 | |---|---|---|---|---| -| 0.1.0-seed | 2026-09-11 | `87082ad` | `b3a893fbb46711d65383ce29f17db2762c56c405b3d40649671e89649031aa07` | 种子候选包;**尚未做 §4-6 干净环境验证,未发布** | +| 0.1.0-seed | 2026-09-11 | `701c538` | `b83a6c94ba7cbc757b7384699bcb62f5275ea69c8c69dfda2810bad9a7f60a48` | 种子候选包(含 CSP/软删除);**尚未做 §4-6 干净环境验证,未发布**。注:历史因作者邮箱重写过一次(旧 d082ded → 701c538,树内容相同) | +| ~~0.1.0-seed~~ | 2026-09-11 | ~~`87082ad`~~ | `b3a893fbb46711d65383ce29f17db2762c56c405b3d40649671e89649031aa07` | 已废弃(CSP 关闭、硬删除),勿分发 | diff --git a/docs/seed-manual.md b/docs/seed-manual.md index 9563506..1b22ebe 100644 --- a/docs/seed-manual.md +++ b/docs/seed-manual.md @@ -17,6 +17,7 @@ - **卸载应用不会删除数据目录**。卸载只移除程序本体与应用界面设置(主题、布局偏好等); 重新安装后数据原样回来。 - 建议偶尔把整个数据目录复制到别处(或网盘 / 移动硬盘)作为额外备份。 +- **删除是可恢复的**:在应用里删除任务 / 收件箱条目,文件会移到数据目录的 `.trash\` 文件夹(带删除时间戳),不会物理消失——误删后把它移回原目录即可;也可以从 git 历史找回。 ## 3. 版本保护(快照) diff --git a/index.html b/index.html index 1301f8d..914d414 100644 --- a/index.html +++ b/index.html @@ -10,28 +10,7 @@ content="科研人员的思路整理:空间、项目、任务与按日期追加的上下文日志。" /> - +
diff --git a/public/theme-init.js b/public/theme-init.js new file mode 100644 index 0000000..b98fd20 --- /dev/null +++ b/public/theme-init.js @@ -0,0 +1,23 @@ +// public/theme-init.js — 首帧主题恢复(防闪烁)。 +// 外置为文件而非内联脚本:生产 CSP 为 script-src 'self',内联会被拦截。 +// 必须同步执行且先于任何渲染。 +(function () { + try { + var r = localStorage.getItem("rt-settings-v1"); + var s = r ? JSON.parse(r).state || {} : {}; + var t = s.theme || "light"; + if (t === "system") + t = matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + var e = document.documentElement; + e.dataset.theme = t; + e.dataset.canvas = s.canvas || "white"; + e.dataset.density = s.density || "compact"; + e.dataset.radius = s.radius || "md"; + e.dataset.sidebar = s.sidebarContrast || "soft"; + e.dataset.font = s.font || "sans"; + e.dataset.width = s.editorWidth || "medium"; + if (s.fontSize) e.style.setProperty("--text-ui", s.fontSize + "px"); + } catch (e) {} +})(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e85ce64..39571f5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -20,7 +20,8 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost", + "devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420" } }, "bundle": { diff --git a/src/lib/watcher.ts b/src/lib/watcher.ts index 4fbb7d7..cb975e1 100644 --- a/src/lib/watcher.ts +++ b/src/lib/watcher.ts @@ -39,7 +39,8 @@ export async function startWatcher(handlers: WatchHandlers): Promise<() => void> !rel.startsWith(".git/") && // git 内部簿记对 agent 无意义 !rel.endsWith(".rt-tmp") && // 原子写残片 !rel.endsWith(".rt-bak") && // 原子写备份 - !rel.startsWith(".activity/"), + !rel.startsWith(".activity/") && + !rel.startsWith(".trash/"), // 软删除暂存(恢复由人操作 spaces/ 时自然可见) ); if (external.length > 0) { void store.logExternalModify(external); diff --git a/src/storage/core.ts b/src/storage/core.ts index 05dc623..b9cf3a0 100644 --- a/src/storage/core.ts +++ b/src/storage/core.ts @@ -298,6 +298,7 @@ export const paths = { `spaces/${spaceId}/projects/${projectId}/tasks/${taskId}`, inboxDir: () => "inbox", inboxItem: (id: string) => `inbox/${id}`, + trashDir: () => ".trash", weeklyDir: () => "weekly", weeklyFile: (id: string) => `weekly/${id}`, dailyDir: () => "daily", diff --git a/src/storage/index.ts b/src/storage/index.ts index aa68a25..62a5341 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -446,8 +446,29 @@ export class StorageDataAccess implements AppDataAccess { async deleteTask(spaceId: string, projectId: string, taskId: string): Promise { const rel = core.paths.taskFile(spaceId, projectId, taskId); - await this.remove(rel); - await this.logActivity("app", "task", rel, "delete_task"); + const trashedTo = await this.moveToTrash(rel); + await this.logActivity("app", "task", rel, "delete_task", { trashedTo }); + } + + /** + * 软删除:移入数据目录 .trash/(时间戳前缀防覆盖),绝不物理删除。 + * 事务写保护不了 delete——这是当天新文件尚未进 git 快照时唯一的丢失防线; + * 恢复 = 把文件从 .trash/ 移回原位(或从 git 历史找回)。 + */ + private async moveToTrash(rel: string): Promise { + const src = this.abs(rel); + if (!(await this.adapter.exists(src))) throw new Error(`文件不存在:${rel}`); + await this.adapter.mkdir(this.abs(core.paths.trashDir())); + const trashEntries = await this.adapter.readDir(this.abs(core.paths.trashDir())); + const now = new Date(); + const p = (n: number) => String(n).padStart(2, "0"); + const stamp = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}T${p(now.getHours())}-${p(now.getMinutes())}-${p(now.getSeconds())}`; + const base = `${stamp}_${rel.split("/").pop()}`; + const trashedRel = `${core.paths.trashDir()}/${core.uniqueId(base, trashEntries.map((e) => e.name))}`; + await this.adapter.rename(src, this.abs(trashedRel)); + markSelfWrite(src); + markSelfWrite(this.abs(trashedRel)); + return trashedRel; } // —— 收件箱(M2③:快速捕获;文件 = inbox/YYYY-MM-DD-HHMMSS.md,正文即内容) —— @@ -493,8 +514,8 @@ export class StorageDataAccess implements AppDataAccess { } async deleteInboxItem(id: string): Promise { - await this.remove(core.paths.inboxItem(id)); - await this.logActivity("app", "inbox", core.paths.inboxItem(id), "inbox_delete"); + const trashedTo = await this.moveToTrash(core.paths.inboxItem(id)); + await this.logActivity("app", "inbox", core.paths.inboxItem(id), "inbox_delete", { trashedTo }); } } diff --git a/src/storage/weekly.ts b/src/storage/weekly.ts index e1fa93f..5cbec88 100644 --- a/src/storage/weekly.ts +++ b/src/storage/weekly.ts @@ -30,8 +30,11 @@ interface TaskRef { } function diffDays(later: string, earlier: string): number { - const a = Date.UTC(...(later.split("-").map(Number) as [number, number, number])); - const b = Date.UTC(...(earlier.split("-").map(Number) as [number, number, number])); + // Date.UTC 的 month 是 0–11;Markdown 日期是 1–12,必须 -1(跨月边界差 1–3 天) + const [ly, lm, ld] = later.split("-").map(Number); + const [ey, em, ed] = earlier.split("-").map(Number); + const a = Date.UTC(ly, (lm ?? 1) - 1, ld ?? 1); + const b = Date.UTC(ey, (em ?? 1) - 1, ed ?? 1); return Math.round((a - b) / 86400000); } @@ -81,7 +84,8 @@ export function buildWeeklySkeleton(input: WeeklySkeletonInput): string { const mmdd = ev.date.slice(5).replace("-", "/"); switch (ev.event) { case "create_task": - if (name) lines.push(`- ${mmdd} 新建任务「${name}」· ${ref ? loc(ref) : ""}`.trimEnd()); + // ref 解析不到(跨周新建后已删等)时不留尾部「·」 + if (name) lines.push(ref ? `- ${mmdd} 新建任务「${name}」· ${loc(ref)}` : `- ${mmdd} 新建任务「${name}」`); break; case "set_status": { const d = (ev.detail ?? {}) as { from?: string; to?: string }; diff --git a/tests/storageNode.test.ts b/tests/storageNode.test.ts index b7cd931..e9f9aca 100644 --- a/tests/storageNode.test.ts +++ b/tests/storageNode.test.ts @@ -93,7 +93,7 @@ describe("StorageDataAccess 生命周期", () => { const space = await store.createSpace("科研"); expect(space.id).toBe("科研"); const project = await store.createProject(space.id, "DFT 论文投稿"); - const task = await store.createTask(project.id ? space.id : space.id, project.id, "回复审稿人", "补充静电结合能数据"); + const task = await store.createTask(space.id, project.id, "回复审稿人", "补充静电结合能数据"); const raw = await fsp.readFile(join(dataDir, "spaces", "科研", "projects", project.id, "tasks", task.id), "utf8"); expect(raw).toContain("title: 回复审稿人"); @@ -175,16 +175,33 @@ describe("StorageDataAccess 生命周期", () => { expect(items.some((i) => i.id === item.id)).toBe(false); }); - it("删除任务后列表消失;项目描述可更新", async () => { + it("删除任务 → 软删除进 .trash(可恢复);项目描述可更新", async () => { const [space] = await store.listSpaces(); const project = space.projects[0]; const task = await store.createTask(space.id, project.id, "待删除的任务"); + const taskRel = `spaces/${space.id}/projects/${project.id}/tasks/${task.id}`; + const taskPath = join(dataDir, "spaces", space.id, "projects", project.id, "tasks", task.id); + await fsp.appendFile(taskPath, "\n### 2026-09-11 · 我\n重要:别丢这段上下文。\n", "utf8"); + await store.deleteTask(space.id, project.id, task.id); const after = await store.listSpaces(); expect( after.flatMap((s) => s.projects.flatMap((p) => p.tasks.map((t) => t.id))), ).not.toContain(task.id); + // 软删除:文件进了 .trash/ 而非物理消失(当天未进 git 快照的新文件也不丢) + const trashDir = join(dataDir, ".trash"); + const trashed = (await fsp.readdir(trashDir)).filter((f) => f.endsWith(`_${task.id}`)); + expect(trashed).toHaveLength(1); + const trashedContent = await fsp.readFile(join(trashDir, trashed[0]), "utf8"); + expect(trashedContent).toContain("别丢这段上下文"); + + // 恢复演练:从 .trash 移回原位 → 任务可读、内容完整 + await fsp.rename(join(trashDir, trashed[0]), taskPath); + const restored = await store.getTask(space.id, project.id, task.id); + expect(restored.entries.some((e) => e.text.includes("别丢这段上下文"))).toBe(true); + void taskRel; + await store.updateProjectDescription(space.id, project.id, "# 目标\nPRB 投稿。"); const afterDesc = await store.listSpaces(); expect(afterDesc[0].projects[0].description).toContain("PRB 投稿"); diff --git a/tests/weekly.test.ts b/tests/weekly.test.ts index 9b22550..23b5b7a 100644 --- a/tests/weekly.test.ts +++ b/tests/weekly.test.ts @@ -122,8 +122,7 @@ describe("buildWeeklySkeleton(确定性骨架)", () => { EV({ date: "2026-09-10", event: "append_entry" }), EV({ date: "2026-09-10", - event: "external_modify_placeholder", // external actor 走汇总 - actor: "external", + actor: "external", // external actor 走汇总 event: "modify", entity: "file", id: "spaces\\research\\projects\\paper-dft\\tasks\\2026-09-01-reply.md", @@ -171,6 +170,48 @@ describe("buildWeeklySkeleton(确定性骨架)", () => { const empty = buildWeeklySkeleton({ ...BASE, events: [] }); expect(empty).toContain("本周没有记录到操作。"); }); + + it("create_task 解析不到项目时不留尾部「·」", () => { + expect(md).toContain("新建任务「新任务X」"); + expect(md).not.toContain("「新任务X」·"); + }); +}); + +describe("滞留天数边界(diffDays 月份索引回归)", () => { + const mkTask = (updated: string) => ({ + id: `2026-01-01-t.md`, + title: `更新于${updated}`, + status: "doing" as const, + star: false, + next: "", + created: "2026-01-01", + updated, + entries: [], + }); + const spaces: Space[] = [ + { + id: "s", + name: "空间", + projects: [ + { + id: "p", + name: "项目", + description: "", + // 周日 end=2026-09-13:08-30 恰好 14 天(应判滞留),08-31 恰好 13 天(不应判) + tasks: [mkTask("2026-08-30"), mkTask("2026-08-31")], + }, + ], + }, + ]; + const md = buildWeeklySkeleton({ ...BASE, spaces, events: [] }); + const attention = md.split("## 需要关注")[1].split("## 搁置")[0]; + + it("恰好 14 天未更新 → 判为滞留(且天数正确:跨月不减错)", () => { + expect(attention).toContain("已 14 天未更新"); + }); + it("恰好 13 天未更新 → 不判滞留", () => { + expect(attention).not.toContain("已 13 天未更新"); + }); }); describe("任务定位与排序修正", () => {