@yorz/cli 0.4.2 → 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.
Files changed (68) hide show
  1. package/README.md +5 -2
  2. package/dist/cli/index.js +1600 -1167
  3. package/dist/gui/assets/{abnfDiagram-VRR7QNED-C1LflvIY.js → abnfDiagram-VRR7QNED-CuRplRPV.js} +1 -1
  4. package/dist/gui/assets/{arc-CLVcmGRI.js → arc-l8OUkR61.js} +1 -1
  5. package/dist/gui/assets/{architectureDiagram-ZJ3FMSHR-CshNMQ27.js → architectureDiagram-ZJ3FMSHR-CBUrMLYH.js} +1 -1
  6. package/dist/gui/assets/{blockDiagram-677ZJIJ3-D5dDqKKl.js → blockDiagram-677ZJIJ3-B0h7xBKD.js} +1 -1
  7. package/dist/gui/assets/{c4Diagram-LMCZKHZV-DRVpuaAe.js → c4Diagram-LMCZKHZV-8O5EPgBt.js} +1 -1
  8. package/dist/gui/assets/channel-DfOs8mBK.js +1 -0
  9. package/dist/gui/assets/{chunk-2Q5K7J3B-CyfpeX3b.js → chunk-2Q5K7J3B-Bljgbhrq.js} +1 -1
  10. package/dist/gui/assets/{chunk-32BRIVSS-Bpb0lkX5.js → chunk-32BRIVSS-CWu7ELew.js} +1 -1
  11. package/dist/gui/assets/{chunk-5VM5RSS4-Cxz0034S.js → chunk-5VM5RSS4-CgCexDnG.js} +1 -1
  12. package/dist/gui/assets/{chunk-EX3LRPZG-KRbOxYPK.js → chunk-EX3LRPZG-DOWG8EpG.js} +1 -1
  13. package/dist/gui/assets/{chunk-JWPE2WC7-Xrz2UilG.js → chunk-JWPE2WC7-BMlmWtUB.js} +1 -1
  14. package/dist/gui/assets/{chunk-MOJQB5TN-BNaU7zJh.js → chunk-MOJQB5TN-DJUqDD4B.js} +1 -1
  15. package/dist/gui/assets/{chunk-RYQCIY6F-DWByW4ue.js → chunk-RYQCIY6F-BFi9jUjp.js} +1 -1
  16. package/dist/gui/assets/{chunk-V7JOEXUC-wqPxL9c_.js → chunk-V7JOEXUC-ByXHNLJe.js} +1 -1
  17. package/dist/gui/assets/{chunk-VR4S4FIN-CpNkwfNF.js → chunk-VR4S4FIN-B_D6T911.js} +1 -1
  18. package/dist/gui/assets/{chunk-XXDRQBXY-DfzP0kY8.js → chunk-XXDRQBXY-DjP4EgJp.js} +1 -1
  19. package/dist/gui/assets/classDiagram-OUVF2IWQ-BR-pwHie.js +1 -0
  20. package/dist/gui/assets/classDiagram-v2-EOCWNBFH-BR-pwHie.js +1 -0
  21. package/dist/gui/assets/{cose-bilkent-JH36ORCC-DRxVn8EA.js → cose-bilkent-JH36ORCC-CjD0qEvM.js} +1 -1
  22. package/dist/gui/assets/{cynefin-VYW2F7L2-Dgo2oRfE.js → cynefin-VYW2F7L2-C97W66bH.js} +1 -1
  23. package/dist/gui/assets/{cynefinDiagram-TSTJHNR4-DkOfGZKA.js → cynefinDiagram-TSTJHNR4-D4Wahgq2.js} +1 -1
  24. package/dist/gui/assets/{dagre-VKFMJZFB-B9lbU1Y5.js → dagre-VKFMJZFB-0TWp1RT0.js} +1 -1
  25. package/dist/gui/assets/{diagram-FQU43EPY-L4aTZZob.js → diagram-FQU43EPY-BV9a66lb.js} +1 -1
  26. package/dist/gui/assets/{diagram-G47NLZAW-C7EjvTdr.js → diagram-G47NLZAW-DKzlyZEe.js} +1 -1
  27. package/dist/gui/assets/{diagram-NH7WQ7WH-B3oafVE9.js → diagram-NH7WQ7WH-BQhml9wE.js} +1 -1
  28. package/dist/gui/assets/{diagram-OA4YK3LP-B5Xr4fVb.js → diagram-OA4YK3LP-DCzpOYMX.js} +1 -1
  29. package/dist/gui/assets/{diagram-WEI45ONY-CizYf4-d.js → diagram-WEI45ONY-DXr3HM2C.js} +1 -1
  30. package/dist/gui/assets/{ebnfDiagram-CCIWWBDH-Cdp4Ck2s.js → ebnfDiagram-CCIWWBDH-Ce9bZRby.js} +1 -1
  31. package/dist/gui/assets/{erDiagram-Q63AITRT-D_HQYmPu.js → erDiagram-Q63AITRT-QMnEo8uw.js} +1 -1
  32. package/dist/gui/assets/{flowDiagram-23GEKE2U-DOp2pyM-.js → flowDiagram-23GEKE2U-DGTJo1bv.js} +1 -1
  33. package/dist/gui/assets/{ganttDiagram-NO4QXBWP-jeuE6-Kq.js → ganttDiagram-NO4QXBWP-C4Z6wul5.js} +1 -1
  34. package/dist/gui/assets/{gitGraphDiagram-IHSO6WYX-BmHbinZO.js → gitGraphDiagram-IHSO6WYX-D3CUhb8K.js} +1 -1
  35. package/dist/gui/assets/index-BCBAWuCy.css +1 -0
  36. package/dist/gui/assets/index-DQGimWjn.js +36 -0
  37. package/dist/gui/assets/{infoDiagram-FWYZ7A6U-C1JAC0zw.js → infoDiagram-FWYZ7A6U-DrEI_bIp.js} +1 -1
  38. package/dist/gui/assets/{ishikawaDiagram-FXEZZL3T-C7gxtQ09.js → ishikawaDiagram-FXEZZL3T-SLiUSwvC.js} +1 -1
  39. package/dist/gui/assets/{journeyDiagram-5HDEW3XC-Bm09XlrS.js → journeyDiagram-5HDEW3XC-DOa-T3HP.js} +1 -1
  40. package/dist/gui/assets/{kanban-definition-HUTT4EX6-w-2VnSoA.js → kanban-definition-HUTT4EX6-CasK-nvo.js} +1 -1
  41. package/dist/gui/assets/{linear-DuGFha_A.js → linear-C0pax1ZS.js} +1 -1
  42. package/dist/gui/assets/{mermaid.core-DDfmGYIx.js → mermaid.core-DIdNUSOF.js} +4 -4
  43. package/dist/gui/assets/{mindmap-definition-LN4V7U3C-DSWPnA6W.js → mindmap-definition-LN4V7U3C-a_I5uzJ4.js} +1 -1
  44. package/dist/gui/assets/{pegDiagram-2B236MQR-Css8B66d.js → pegDiagram-2B236MQR-ChhsNm3o.js} +1 -1
  45. package/dist/gui/assets/{pieDiagram-ENE6RG2P-CSyx5YUR.js → pieDiagram-ENE6RG2P-CZZoIf-Y.js} +1 -1
  46. package/dist/gui/assets/{quadrantDiagram-ABIIQ3AL-Dd0BHS30.js → quadrantDiagram-ABIIQ3AL-DVXyLGdq.js} +1 -1
  47. package/dist/gui/assets/{railroadDiagram-RFXS5EU6-6tQYC8Kx.js → railroadDiagram-RFXS5EU6-BSfkVeLk.js} +1 -1
  48. package/dist/gui/assets/{requirementDiagram-TGXJPOKE-CIgyhQt1.js → requirementDiagram-TGXJPOKE-BBsnKguR.js} +1 -1
  49. package/dist/gui/assets/{sankeyDiagram-HTMAVEWB-CV8IW8dn.js → sankeyDiagram-HTMAVEWB-CpY2Xf40.js} +1 -1
  50. package/dist/gui/assets/{sequenceDiagram-DBY2YBRQ-XrPzK96U.js → sequenceDiagram-DBY2YBRQ-C88OWtrK.js} +1 -1
  51. package/dist/gui/assets/{sizeCapture-X5ZJPWSS-B0RuqhJy.js → sizeCapture-X5ZJPWSS-55d84kzI.js} +1 -1
  52. package/dist/gui/assets/{stateDiagram-2N3HPSRC-BbX5u2al.js → stateDiagram-2N3HPSRC-DEpNqk6b.js} +1 -1
  53. package/dist/gui/assets/stateDiagram-v2-6OUMAXLB-DgpJquA8.js +1 -0
  54. package/dist/gui/assets/{swimlanes-5IMT3BWC-eN6NeRxH.js → swimlanes-5IMT3BWC-BbZvxxWQ.js} +2 -2
  55. package/dist/gui/assets/swimlanesDiagram-G3AALYLV-1VFHiN5k.js +8 -0
  56. package/dist/gui/assets/{timeline-definition-FHXFAJF6-Cwx4rfjD.js → timeline-definition-FHXFAJF6-DXJKmih1.js} +1 -1
  57. package/dist/gui/assets/{vennDiagram-L72KCM5P-BfVuNGYO.js → vennDiagram-L72KCM5P-Dsx51jcc.js} +1 -1
  58. package/dist/gui/assets/{wardleyDiagram-EHGQE667-tTfKUfA2.js → wardleyDiagram-EHGQE667-DFchg4su.js} +1 -1
  59. package/dist/gui/assets/{xychartDiagram-FW5EYKEG-BMDTawgV.js → xychartDiagram-FW5EYKEG-9A_QhEe5.js} +1 -1
  60. package/dist/gui/index.html +2 -2
  61. package/package.json +1 -3
  62. package/dist/gui/assets/channel-3Bz_lxm-.js +0 -1
  63. package/dist/gui/assets/classDiagram-OUVF2IWQ-DX15YBOn.js +0 -1
  64. package/dist/gui/assets/classDiagram-v2-EOCWNBFH-DX15YBOn.js +0 -1
  65. package/dist/gui/assets/index-Ck5Q41lR.js +0 -36
  66. package/dist/gui/assets/index-DO4me3fw.css +0 -1
  67. package/dist/gui/assets/stateDiagram-v2-6OUMAXLB-DPSUOLxs.js +0 -1
  68. package/dist/gui/assets/swimlanesDiagram-G3AALYLV-BJJrQBKd.js +0 -8
package/dist/cli/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { homedir, platform, networkInterfaces } from "node:os";
3
3
  import { Command, Option } from "commander";
4
4
  import { stat, readFile, writeFile, rm, mkdir, access, rename, readdir, appendFile, copyFile } from "node:fs/promises";
5
- import { join, dirname, extname, resolve, sep, isAbsolute, basename as basename$1, relative, normalize } from "node:path";
5
+ import { join, dirname, extname, isAbsolute, resolve, basename as basename$1, sep, relative, normalize } from "node:path";
6
6
  import { createHash, randomUUID, randomBytes } from "node:crypto";
7
7
  import { spawn, execFile } from "node:child_process";
8
8
  import { existsSync, openSync, closeSync, statSync, readSync, mkdirSync, watch, createReadStream } from "node:fs";
@@ -21,9 +21,37 @@ import { Codex } from "@openai/codex-sdk";
21
21
  import { createOpencode } from "@opencode-ai/sdk";
22
22
  import { createInterface as createInterface$1 } from "node:readline/promises";
23
23
  import MarkdownIt from "markdown-it";
24
- const version = "0.4.2";
24
+ const name = "@yorz/cli";
25
+ const version = "0.5.0";
26
+ const type = "module";
27
+ const description = "";
28
+ const bin = { "yorz": "./dist/cli/index.js" };
29
+ const engines = { "node": ">=20" };
30
+ const files = ["dist", "README.md"];
31
+ const keywords = ["ai", "agent", "spec-driven", "decision-os", "claude-code", "opencode", "workflow", "cli"];
32
+ const license = "LGPL-3.0-or-later";
33
+ const repository = { "type": "git", "url": "git+https://github.com/hughfenghen/YorZ.git" };
34
+ const homepage = "https://github.com/hughfenghen/YorZ#readme";
35
+ const publishConfig = { "access": "public" };
36
+ const scripts = { "build": "pnpm run build:cli && pnpm run build:gui", "build:cli": "vite build", "build:gui": "vite build --config vite.gui.config.ts", "dev:cli": "pnpm run build:cli && node dist/cli/index.js serve --port 7424 --foreground", "dev:gui": "vite --config vite.gui.config.ts", "typecheck": "tsc -b", "test": "vitest run", "test:agent": "node scripts/test-agent.mjs", "test:e2e": "playwright test", "format": 'prettier --write "src/**/*.{ts,tsx,md,css}"', "prepublishOnly": "pnpm run build" };
37
+ const dependencies = { "@anthropic-ai/claude-agent-sdk": "^0.3.207", "@hono/node-server": "^2.0.4", "@openai/codex-sdk": "^0.144.1", "@opencode-ai/sdk": "^1.17.18", "chokidar": "^5.0.0", "commander": "^12.1.0", "gray-matter": "^4.0.3", "hono": "^4.12.25", "jsdom": "^25.0.1", "markdown-it": "^14.2.0", "mermaid": "^11.0.0", "morphdom": "^2.7.8", "timeago.js": "^4.0.2", "trash": "^10.1.1" };
38
+ const devDependencies = { "@kobalte/core": "^0.13.12", "@playwright/test": "^1.61.0", "@solidjs/router": "^0.16.1", "@types/jsdom": "^21.1.7", "@types/markdown-it": "^14.1.2", "@types/node": "^22.10.0", "autoprefixer": "^10.5.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "concurrently": "^9.1.0", "highlight.js": "^11.11.1", "i18next": "^26.3.6", "i18next-browser-languagedetector": "^8.2.1", "lucide-solid": "^1.24.0", "markdown-it-task-lists": "^2.1.1", "postcss": "^8.5.16", "prettier": "^3.4.0", "solid-js": "^1.9.13", "tailwind-merge": "^3.6.0", "tailwindcss": "^3.4.19", "tailwindcss-animate": "^1.0.7", "typescript": "^5.7.0", "vite": "^6.0.0", "vite-plugin-solid": "^2.11.12", "vitest": "^2.1.0" };
25
39
  const pkg = {
26
- version
40
+ name,
41
+ version,
42
+ type,
43
+ description,
44
+ bin,
45
+ engines,
46
+ files,
47
+ keywords,
48
+ license,
49
+ repository,
50
+ homepage,
51
+ publishConfig,
52
+ scripts,
53
+ dependencies,
54
+ devDependencies
27
55
  };
28
56
  const claudeAdapter = {
29
57
  name: "claude",
@@ -51,11 +79,11 @@ const ADAPTERS = {
51
79
  opencode: opencodeAdapter,
52
80
  codex: codexAdapter
53
81
  };
54
- function getAdapter(name) {
55
- if (name in ADAPTERS) return ADAPTERS[name];
56
- throw new Error(`Unknown agent: ${name}. Supported: ${Object.keys(ADAPTERS).join(", ")}`);
82
+ function getAdapter(name2) {
83
+ if (name2 in ADAPTERS) return ADAPTERS[name2];
84
+ throw new Error(`Unknown agent: ${name2}. Supported: ${Object.keys(ADAPTERS).join(", ")}`);
57
85
  }
58
- const __vite_glob_0_0 = '---\nname: yorz-debug\ndescription: 以资深工程师的调试纪律定位并修复疑难 bug——假设→取证→验证的证据闭环,靠 debug.md 活文档与 git 快照兜住污染。\n---\n\n# YorZ Debug 深度调试 Skill\n\n指导 Agent 以**资深工程师的调试纪律**逐步逼近疑难 bug 的根因:不凭猜测改代码,坚持「假设 → 取证 → 验证 → 缩小范围 → 再假设」的证据闭环。调试过程赋予更大的自由度(临时改代码、Mock 数据、加日志、建临时验证入口),同时用 `debug.md` 活文档 + `git stash create` 快照兜住"污染性改动不进入最终提交"。\n\n> 本 skill **不复用** yorz-spec 的 plan/tasks/execute 状态机。真相以 `debug.md` 为单一载体;Agent 持续推进直至需要用户回传证据(人在环路),或根因确认并收尾。\n\n## 何时进入本 skill\n\n- **独立触发(无需 spec / UI)**:用户在 Agent 对话中直接点名「用 yorz-debug 调这个 bug」。此时可能没有任何 spec 上下文——按下方「无 spec 场景」处理 `debug.md` 落点。\n- **追加任务勾选 Debug**:用户在 SpecDetail 追加 `fix` 任务时勾选「Debug 模式」,后端 prompt 指向本 skill。\n- **重入**:spec 目录已存在 `status: debugging` 的 `debug.md`(有未收尾记录块),run/append 路由自动切到本 skill。此时**不新建记录块**,定位活跃记录块(frontmatter `active: NNN`)续跑。\n\n## 输入约定\n\n- `spec_dir`(**可选**):spec 目录路径(含 `spec.md`)。给出时 `debug.md` 建在此目录,与 `spec.md` 同级。\n- 待调试 bug 的描述:来自追加任务行 / 用户 prompt。\n- 运行服务上下文(**可选**):由 YorZ Service 注入的当前项目运行中命令服务列表,包含 `runId`、`name`、`cli`、`pid`、`startedAt`、`logFile` 等字段。\n\n### debug.md 落点\n\n- **有 spec 上下文**:`debug.md` 落 `spec_dir`(与 `spec.md` 同级)。\n- **无 spec 场景(独立触发)**:没有 `spec_dir` 时,`debug.md` 落**当前工作目录**;若用户在 prompt 中显式指定了路径,则以用户指定为准。其余多记录模型、快照、脚手架、收尾流程与有 spec 时**完全一致**。\n\n## debug.md:多记录活文档\n\n**一个 `debug.md` 承载同一 spec 生命周期内的多次调试**(某次收尾后又发现新 bug,就追加下一条记录,不覆盖历史)。它同时是:调试活文档、**Debug 模式的持久化标记**(存在未收尾记录 = 处于 Debug 模式)、脚手架是否清理干净的守卫。\n\n### 文件级 frontmatter\n\n```yaml\n---\nstatus: debugging # debugging(存在未收尾记录)| resolved(全部记录已收尾)\nactive: 2 # 当前活跃记录编号;resolved 时留空\nupdated_at: \'2026-07-19 15:54:37\' # 本地秒级字符串\n---\n```\n\n### 正文按「记录块」组织\n\n每次进入 Debug **在文末追加**一个记录块,编号 `NNN` 从 1 递增、**不复用**、不覆盖既有块:\n\n```markdown\n## Debug 1 · <bug 一句话简述>\n\n- 状态:debugging # debugging | resolved\n- 快照:<git stash create 得到的 SHA>\n- 进入时间:\'2026-07-19 15:54:37\'\n\n### 1.1 Bug 现象与复现\n\n...\n\n### 1.2 关联链路分析\n\n...\n\n### 1.3 Debug 基线\n\n快照 SHA + 进入时间;`git diff <SHA>` 为退出闸门基准。\n\n### 1.4 假设看板\n\n...\n\n### 1.5 证据\n\n...\n\n### 1.6 脚手架清单\n\n...\n\n### 1.7 收尾核对\n\n...\n```\n\n### 生命周期\n\n- **创建 / 进入**:`debug.md` 不存在则创建(写文件 frontmatter + 第 1 个记录块);已存在则追加 `## Debug <max+1>`,置文件 `status: debugging`、`active: <新编号>`。进入后**立刻**打快照(见「污染防线」)。\n- **单块收尾**:把该记录块块头 `状态` 置 `resolved`,脚手架逐条核销,`git diff <SNAP>` 校验只剩合法修复。\n- **文件 status 收敛**:单块收尾后,若**所有**记录块均 `resolved`,置文件 `status: resolved`、清空 `active`;否则保持 `debugging` 且 `active` 指向仍在调试的块。\n- **归档**:收尾**保留** `debug.md` 供复盘,绝不删除。\n\n## 核心调试循环\n\n**分析 → 规划 → 假设 → 实施 → 验证 → 缩小范围 → 再假设**,直至根因被硬证据锁定。\n\n```mermaid\nflowchart TD\n Start["接手 bug"] --> Repro{"能稳定复现?"}\n Repro -->|否| GetRepro["补齐复现步骤<br/>(问用户 / 自行构造)"]\n GetRepro --> Repro\n Repro -->|是| Analyze["分析关联链路<br/>写入 debug.md"]\n Analyze --> Hypo["提出可证伪假设<br/>成立看到 X / 不成立看到 Y"]\n Hypo --> Probe["写验证性代码 / 加日志<br/>登记脚手架清单"]\n Probe --> Eval{"证据指向?"}\n Eval -->|证伪| Exclude["记入已排除假设 + 依据"]\n Exclude --> Narrow["缩小范围(二分/控制变量)"]\n Narrow --> Hypo\n Eval -->|坐实根因| Fix["写最终修复"]\n Fix --> Verify{"重跑复现步骤<br/>问题消失?"}\n Verify -->|否| Hypo\n Verify -->|是| Close["进入收尾"]\n```\n\n### 方法层\n\n- **二分法**:在可疑区间(提交历史 / 代码路径 / 数据范围)二分定位,每次砍掉一半。\n- **控制变量法**:一次只改一个变量,隔离单一因素的影响。\n- **遍历可能分支**:对有限的分支/枚举/状态逐一验证,排除法收敛。\n\n## 硬约束(资深工程师容易做、Agent 容易漏)\n\n1. **先复现,再调试**:无法稳定复现,**不得进入修改阶段**——否则无从验证修没修好。复现不了就先构造复现路径或请用户提供。\n2. **假设必须可证伪**:提出假设时,同时写出"若成立会看到 X,若不成立会看到 Y",再去取证。写进「假设看板」。\n3. **记录已排除假设 + 依据**:每否定一个假设,连同证据写入看板,**避免兜圈子反复验证同一已否定项**。\n4. **禁止无证据修复**:拿到指向根因的**硬证据**(日志 / 截图 / 可复现的最小用例)前,**只许写验证性代码,不许写最终修复**。\n5. **人机协作**:优先主动复现 Bug、获取日志进行分析判断,如果无途径复现 Bug、获取日志,也可暂停执行,让用户配合复现 Bug、回传日志。\n6. **退出双条件**:根因有硬证据 **且** 修复后重跑复现步骤、证据显示问题消失——**缺一不可**。\n\n### 日志纪律\n\n- **可读可复制**:打印结构化字符串而非整个 object(如 拼接关键字段 k1=v1&k2=v2 或 `JSON.stringify(x)`),避免直接打印对象或复杂结构变量的引用。\n- **避免刷屏**:不要在循环/高频路径里打印大量日志;聚焦怀疑环节,必要时带唯一标记便于检索。\n- **可定位**:日志带上下文(哪个函数 / 哪个分支 / 关键变量值),便于判定假设成立与否。\n\n## 更大的自由度(Debug 模式内允许)\n\n- 编写临时代码(条件短路)或注释有意义代码,控制程序进入 / 屏蔽特定分支;\n- 临时篡改变量值、Mock API 返回,让程序走到特定分支;\n- 编写临时独立测试页面 / 入口,简化复现路径、直接验证怀疑环节;\n- 联网查询第三方 SDK 文档、向用户询问必要信息;\n- 允许 lint / typecheck / build / 单测出现错误,允许**临时**跳过这些检查。\n\n> 每一处临时改动都**必须**在「脚手架清单」登记一行(文件 + 位置 + 类型:短路 / Mock / 注释 / 临时页面 / 临时日志),收尾逐条核销。**未登记 = 会漏还原**。\n\n## 污染防线(强制流程)\n\n在**当前项目与分支就地调试**,不开 worktree;靠快照 + 脚手架清单 + diff 兜住污染。\n\n```mermaid\nflowchart TD\n Enter["进入 Debug(新记录块)"] --> Snap["SNAP = git stash create<br/>零副作用快照当前脏工作区"]\n Snap --> Record["把 SNAP 写入记录块 · Debug 基线"]\n Record --> Loop["调试循环<br/>照常改代码 + 登记脚手架清单"]\n Loop --> Fixed{"根因确认 + 硬证据?"}\n Fixed -->|否| Loop\n Fixed -->|是| Revert["按脚手架清单逐条还原"]\n Revert --> Diff["git diff SNAP"]\n Diff --> Gate{"仅剩合法修复?"}\n Gate -->|否| Revert\n Gate -->|是| Done["置本块 status: resolved<br/>跑变更文件单测/build/typecheck"]\n```\n\n- **进入即打快照**:`git stash create`(**不是** `git stash` / 临时 commit)生成一个记录当前脏工作区的 commit 对象,但**不动工作区 / index / HEAD**。它天然区分"进入前的既有未提交改动"与"调试引入的改动",避免临时 commit 的还原风险。把返回的 SHA 写入记录块「Debug 基线」。\n - 若 `git stash create` 无输出(工作区干净),以当前 `HEAD`(`git rev-parse HEAD`)作为快照基准并注明。\n- **git diff 是退出闸门**:宣告完成前,`git diff <SNAP>` 必须**只剩合法修复**——所有短路 / Mock / 注释 / 临时页面 / 临时日志都已还原。diff 里还有脚手架残留 = 不许收尾。\n\n## 收尾(退出 Debug 模式)\n\n根因确认且修复通过复现验证后:\n\n1. **还原脚手架**:按「脚手架清单」逐条还原临时代码与被注释代码,确保原有逻辑正确;清理不需要的 debug 日志。\n2. **退出闸门**:`git diff <SNAP>` 只剩合法修复(脚手架清单全部核销)。\n3. **常规完整性检查**:跑变更文件关联单测、构建、类型检查。\n4. **落状态**:把该记录块块头 `状态` 置 `resolved`;按「文件 status 收敛」更新文件 frontmatter(全部 resolved → 文件 `status: resolved`、清空 `active`)。\n5. **保留归档**:`debug.md` 保留,供复盘。\n\n> 收尾后如又发现**新** bug,回到本 skill 追加下一条 `## Debug NNN` 记录块(不覆盖历史),重新走一遍循环。\n\n## 写回纪律\n\n- 每次写回 `debug.md` 都更新文件 frontmatter 的 `updated_at`。\n- `updated_at` 用本地秒级 `YYYY-MM-DD HH:mm:ss` 字符串。\n- 记录块编号连续递增、不复用、不重排既有块。\n';
86
+ const __vite_glob_0_0 = '---\nname: yorz-debug\ndescription: 以资深工程师的调试纪律定位并修复疑难 bug——假设→取证→验证的证据闭环,靠 debug.md 活文档与 git 快照兜住污染。\n---\n\n# YorZ Debug 深度调试 Skill\n\n指导 Agent 以**资深工程师的调试纪律**逐步逼近疑难 bug 的根因:不凭猜测改代码,坚持「假设 → 取证 → 验证 → 缩小范围 → 再假设」的证据闭环。调试过程赋予更大的自由度(临时改代码、Mock 数据、加日志、建临时验证入口),同时用 `debug.md` 活文档 + `git stash create` 快照兜住"污染性改动不进入最终提交"。\n\n> 本 skill **不复用** yorz-spec 的 plan/tasks/execute 状态机。真相以 `debug.md` 为单一载体;Agent 持续推进直至需要用户回传证据(人在环路),或根因确认并收尾。\n\n## 何时进入本 skill\n\n- **独立触发(无需 spec / UI)**:用户在 Agent 对话中直接点名「用 yorz-debug 调这个 bug」。此时可能没有任何 spec 上下文——按下方「无 spec 场景」处理 `debug.md` 落点。\n- **追加任务勾选 Debug**:用户在 SpecDetail 追加 `fix` 任务时勾选「Debug 模式」,后端 prompt 指向本 skill。\n- **重入**:spec 目录已存在 `status: debugging` 的 `debug.md`(有未收尾记录块),run/append 路由自动切到本 skill。此时**不新建记录块**,定位活跃记录块(frontmatter `active: NNN`)续跑。\n\n## 输入约定\n\n- `spec_dir`(**可选**):spec 目录路径(含 `spec.md`)。给出时 `debug.md` 建在此目录,与 `spec.md` 同级。\n- 待调试 bug 的描述:来自追加任务行 / 用户 prompt。\n- 运行服务上下文(**可选**):由 YorZ Service 注入的当前项目运行中命令服务列表,包含 `runId`、`name`、`cli`、`pid`、`startedAt`、`logFile` 等字段。\n\n### debug.md 落点\n\n- **有 spec 上下文**:`debug.md` 落 `spec_dir`(与 `spec.md` 同级)。\n- **无 spec 场景(独立触发)**:没有 `spec_dir` 时,Debug 活文档必须落在项目临时目录 `.yorz/tmp/debug/`,文件名必须携带本地秒级时间信息,格式为 `debug-YYYYMMDD-HHmmss.md`;若 YorZ Service prompt 已给出具体文件路径,则以该路径为准。其余多记录模型、快照、脚手架、收尾流程与有 spec 时**完全一致**。\n\n## debug.md:多记录活文档\n\n**一个 `debug.md` 承载同一 spec 生命周期内的多次调试**(某次收尾后又发现新 bug,就追加下一条记录,不覆盖历史)。它同时是:调试活文档、**Debug 模式的持久化标记**(存在未收尾记录 = 处于 Debug 模式)、脚手架是否清理干净的守卫。\n\n### 文件级 frontmatter\n\n```yaml\n---\nstatus: debugging # debugging(存在未收尾记录)| resolved(全部记录已收尾)\nactive: 2 # 当前活跃记录编号;resolved 时留空\nupdated_at: \'2026-07-19 15:54:37\' # 本地秒级字符串\n---\n```\n\n### 正文按「记录块」组织\n\n每次进入 Debug **在文末追加**一个记录块,编号 `NNN` 从 1 递增、**不复用**、不覆盖既有块:\n\n```markdown\n## Debug 1 · <bug 一句话简述>\n\n- 状态:debugging # debugging | resolved\n- 快照:<git stash create 得到的 SHA>\n- 进入时间:\'2026-07-19 15:54:37\'\n\n### 1.1 Bug 现象与复现\n\n...\n\n### 1.2 关联链路分析\n\n...\n\n### 1.3 Debug 基线\n\n快照 SHA + 进入时间;`git diff <SHA>` 为退出闸门基准。\n\n### 1.4 假设看板\n\n...\n\n### 1.5 证据\n\n...\n\n### 1.6 脚手架清单\n\n...\n\n### 1.7 收尾核对\n\n...\n```\n\n### 生命周期\n\n- **创建 / 进入**:`debug.md` 不存在则创建(写文件 frontmatter + 第 1 个记录块);已存在则追加 `## Debug <max+1>`,置文件 `status: debugging`、`active: <新编号>`。进入后**立刻**打快照(见「污染防线」)。\n- **单块收尾**:把该记录块块头 `状态` 置 `resolved`,脚手架逐条核销,`git diff <SNAP>` 校验只剩合法修复。\n- **文件 status 收敛**:单块收尾后,若**所有**记录块均 `resolved`,置文件 `status: resolved`、清空 `active`;否则保持 `debugging` 且 `active` 指向仍在调试的块。\n- **归档**:收尾**保留** `debug.md` 供复盘,绝不删除。\n\n## 核心调试循环\n\n**分析 → 规划 → 假设 → 实施 → 验证 → 缩小范围 → 再假设**,直至根因被硬证据锁定。\n\n```mermaid\nflowchart TD\n Start["接手 bug"] --> Repro{"能稳定复现?"}\n Repro -->|否| GetRepro["补齐复现步骤<br/>(问用户 / 自行构造)"]\n GetRepro --> Repro\n Repro -->|是| Analyze["分析关联链路<br/>写入 debug.md"]\n Analyze --> Hypo["提出可证伪假设<br/>成立看到 X / 不成立看到 Y"]\n Hypo --> Probe["写验证性代码 / 加日志<br/>登记脚手架清单"]\n Probe --> Eval{"证据指向?"}\n Eval -->|证伪| Exclude["记入已排除假设 + 依据"]\n Exclude --> Narrow["缩小范围(二分/控制变量)"]\n Narrow --> Hypo\n Eval -->|坐实根因| Fix["写最终修复"]\n Fix --> Verify{"重跑复现步骤<br/>问题消失?"}\n Verify -->|否| Hypo\n Verify -->|是| Close["进入收尾"]\n```\n\n### 方法层\n\n- **二分法**:在可疑区间(提交历史 / 代码路径 / 数据范围)二分定位,每次砍掉一半。\n- **控制变量法**:一次只改一个变量,隔离单一因素的影响。\n- **遍历可能分支**:对有限的分支/枚举/状态逐一验证,排除法收敛。\n\n## 硬约束(资深工程师容易做、Agent 容易漏)\n\n1. **先复现,再调试**:无法稳定复现,**不得进入修改阶段**——否则无从验证修没修好。复现不了就先构造复现路径或请用户提供。\n2. **假设必须可证伪**:提出假设时,同时写出"若成立会看到 X,若不成立会看到 Y",再去取证。写进「假设看板」。\n3. **记录已排除假设 + 依据**:每否定一个假设,连同证据写入看板,**避免兜圈子反复验证同一已否定项**。\n4. **禁止无证据修复**:拿到指向根因的**硬证据**(日志 / 截图 / 可复现的最小用例)前,**只许写验证性代码,不许写最终修复**。\n5. **人机协作**:优先主动复现 Bug、获取日志进行分析判断,如果无途径复现 Bug、获取日志,也可暂停执行,让用户配合复现 Bug、回传日志。\n6. **退出双条件**:根因有硬证据 **且** 修复后重跑复现步骤、证据显示问题消失——**缺一不可**。\n\n### 日志纪律\n\n- **可读可复制**:打印结构化字符串而非整个 object(如 拼接关键字段 k1=v1&k2=v2 或 `JSON.stringify(x)`),避免直接打印对象或复杂结构变量的引用。\n- **避免刷屏**:不要在循环/高频路径里打印大量日志;聚焦怀疑环节,必要时带唯一标记便于检索。\n- **可定位**:日志带上下文(哪个函数 / 哪个分支 / 关键变量值),便于判定假设成立与否。\n\n## 更大的自由度(Debug 模式内允许)\n\n- 编写临时代码(条件短路)或注释有意义代码,控制程序进入 / 屏蔽特定分支;\n- 临时篡改变量值、Mock API 返回,让程序走到特定分支;\n- 编写临时独立测试页面 / 入口,简化复现路径、直接验证怀疑环节;\n- 联网查询第三方 SDK 文档、向用户询问必要信息;\n- 允许 lint / typecheck / build / 单测出现错误,允许**临时**跳过这些检查。\n\n> 每一处临时改动都**必须**在「脚手架清单」登记一行(文件 + 位置 + 类型:短路 / Mock / 注释 / 临时页面 / 临时日志),收尾逐条核销。**未登记 = 会漏还原**。\n\n## 污染防线(强制流程)\n\n在**当前项目与分支就地调试**,不开 worktree;靠快照 + 脚手架清单 + diff 兜住污染。\n\n```mermaid\nflowchart TD\n Enter["进入 Debug(新记录块)"] --> Snap["SNAP = git stash create<br/>零副作用快照当前脏工作区"]\n Snap --> Record["把 SNAP 写入记录块 · Debug 基线"]\n Record --> Loop["调试循环<br/>照常改代码 + 登记脚手架清单"]\n Loop --> Fixed{"根因确认 + 硬证据?"}\n Fixed -->|否| Loop\n Fixed -->|是| Revert["按脚手架清单逐条还原"]\n Revert --> Diff["git diff SNAP"]\n Diff --> Gate{"仅剩合法修复?"}\n Gate -->|否| Revert\n Gate -->|是| Done["置本块 status: resolved<br/>跑变更文件单测/build/typecheck"]\n```\n\n- **进入即打快照**:`git stash create`(**不是** `git stash` / 临时 commit)生成一个记录当前脏工作区的 commit 对象,但**不动工作区 / index / HEAD**。它天然区分"进入前的既有未提交改动"与"调试引入的改动",避免临时 commit 的还原风险。把返回的 SHA 写入记录块「Debug 基线」。\n - 若 `git stash create` 无输出(工作区干净),以当前 `HEAD`(`git rev-parse HEAD`)作为快照基准并注明。\n- **git diff 是退出闸门**:宣告完成前,`git diff <SNAP>` 必须**只剩合法修复**——所有短路 / Mock / 注释 / 临时页面 / 临时日志都已还原。diff 里还有脚手架残留 = 不许收尾。\n\n## 收尾(退出 Debug 模式)\n\n根因确认且修复通过复现验证后:\n\n1. **还原脚手架**:按「脚手架清单」逐条还原临时代码与被注释代码,确保原有逻辑正确;清理不需要的 debug 日志。\n2. **退出闸门**:`git diff <SNAP>` 只剩合法修复(脚手架清单全部核销)。\n3. **常规完整性检查**:跑变更文件关联单测、构建、类型检查。\n4. **落状态**:把该记录块块头 `状态` 置 `resolved`;按「文件 status 收敛」更新文件 frontmatter(全部 resolved → 文件 `status: resolved`、清空 `active`)。\n5. **保留归档**:`debug.md` 保留,供复盘。\n\n> 收尾后如又发现**新** bug,回到本 skill 追加下一条 `## Debug NNN` 记录块(不覆盖历史),重新走一遍循环。\n\n## 写回纪律\n\n- 每次写回 `debug.md` 都更新文件 frontmatter 的 `updated_at`。\n- `updated_at` 用本地秒级 `YYYY-MM-DD HH:mm:ss` 字符串。\n- 记录块编号连续递增、不复用、不重排既有块。\n';
59
87
  const __vite_glob_0_1 = '{\n "$schema": "https://json-schema.org/draft-07/schema#",\n "version": 2,\n "modules": [\n {\n "module": "SKILL",\n "file": "SKILL.md",\n "relatedCases": []\n }\n ]\n}\n';
60
88
  const __vite_glob_0_2 = '---\nname: yorz-spec\ndescription: Drive YorZ spec docs through plan / tasks / execute stages with deterministic state updates.\n---\n\n# YorZ Spec Drive Skill\n\n将单个 YorZ spec 文档作为状态机执行,围绕 `plan` / `tasks` / `execute` 三阶段推进,全部任务完成后进入终止态 `done`,把状态持续写回文档。md 是单一真相;Agent 持续推进直至阻塞(待确认项、决策、Review)或收尾为 `done` 才退出。\n\n## 如何使用本 skill\n\n本 skill 文档结构精简为 3 个文件;每次接到 spec 任务时,**按以下顺序**按需 Read:\n\n1. **本文档(SKILL.md)**:输入约定、frontmatter 规范、格式约定、自动模式判定、全局硬约束、lint 硬约束、持续推进约束。\n2. **[stages.md](./stages.md)**:plan / tasks / execute / new-spec 四阶段流程与正面示例。\n3. **[review.md](./review.md)**:Review / Git Ops 阶段(独立路径,低频使用)。\n\n[mermaid.md](./mermaid.md) 与 `references/` 目录**仅按需 Read**:只有在判断当前阶段需要输出 mermaid 图表「升维」时才加载,纯状态推进任务不必读取,以节省 context。**plan 阶段的「图形化补充」收尾子步骤**(见 stages.md)会针对 `现状分析`/`技术方案` 两节强制加载 mermaid.md 补图;补图须遵循其核心原则**「图优先、精确信息折叠」**——表层给图,精确细节折叠进 `<details>` 精确层。\n\n> 当 Agent 以 `mode=review` / `mode=git-ops` 启动时,按 [review.md](./review.md) 执行;该路径不进入 plan/tasks/execute 状态机,也不修改 spec.md 的 frontmatter。\n\n## 输入约定\n\n- `spec_path`:**可选**,目标 spec 文档路径。\n - 显式给出时:典型位置 `docs/specs/*.md` 或 `.yorz/specs/<id>/spec.md`,直接更新该 spec。\n - 缺省时按以下顺序解析:\n 1. **从 session 上下文恢复**:扫描当前会话历史中已出现/已读写过的 spec 文档路径;存在多个时优先选取最近一次被读写的;若仍有歧义,向用户确认。\n 2. **从当前 prompt 创建**:若上下文中没有任何 spec 文档,按 [新建 spec 流程](./stages.md#new-spec) 生成路径与骨架。\n- spec 目录默认为 `.yorz/specs`,可由 `<ProjectRoot>/.yorz/config.json` 的 `specsDir` 字段覆盖。\n- `mode`:可选,`plan|tasks|execute|auto`,默认 `auto`。\n- 批注前缀:`!!!`\n\n## frontmatter 规范\n\n每个 spec md 顶部必须有 YAML frontmatter:\n\n```yaml\n---\nstage: plan # plan | tasks | execute | done\nlast_action: 简述上一次动作\nupdated_at: \'2026-06-14 15:42:07\' # 本地秒级字符串\nsummary: 一句话概要,≤ 200 字符\n---\n```\n\n- 字段顺序固定为 `stage` → `last_action` → `updated_at` → `summary`,每个字段独占一行,禁止嵌套与额外字段。\n- `updated_at` 使用本地秒级 `YYYY-MM-DD HH:mm:ss` 字符串。\n- 缺失任意字段时,先补齐再继续。\n\n## Markdown 格式化约定\n\n- 每次写回 spec md 后,**应在仓库支持的条件下运行 Markdown formatter**(优先 `npx prettier --write <spec_path>`);未配置则跳过。\n- formatter 必须保留 YAML frontmatter 不变。\n- 任务清单仅使用单层 `- [ ]` / `- [x]`,避免缩进与嵌套。\n- 二、三级标题必须带层级编号,便于外部按章节号定位:\n - 每次写回前按 body 中 `## ` / `### ` 出现顺序重新编号。\n - 二级标题 `## N. 标题`(N 从 1 起);三级标题 `### N.M 标题`(M 在所属二级下从 1 起)。\n - 该规则不影响 frontmatter 与一级标题(`# `)。\n\n## 自动模式判定顺序(严格按顺序)\n\n`mode=auto` 时,**严格按顺序**判定,命中即停:\n\n1. 若 `spec_path` 缺省:从 session 上下文恢复 spec 路径;恢复失败进入 [新建 spec 流程](./stages.md#new-spec) 并以 `plan` 起步。\n2. **优先扫描 `## 追加任务` 中是否存在 `[open]` 条目**:存在即视为"新输入"信号,进入 `plan`(重开流程)。\n3. 若识别到新增/扩展需求或新增 bug,进入 `plan`(重开流程)。\n4. 若文档存在任意 `!!!` 批注,进入 [tasks](./stages.md#tasks)。\n5. 若 `## 待确认项` 下存在有效条目(非空态 `_暂无_`),停止推进并等待人工批注。\n6. 若 `stage` 已是 `done` 且上述 1–5 均未命中(无新输入/批注/待确认项),直接停止:终止态不再自动推进。\n7. 若存在未完成的**非 `[manual]`** 任务(`- [ ]` 且非 `- [ ] [manual]`),进入 [execute](./stages.md#execute)。\n8. 若任务清单已就绪且不存在未完成的非 manual 任务(走到此处已排除 `[open]`/新需求/批注/待确认项):将 `stage` 置为 `done` 并停止推进(终止态)。\n9. 若 `## 技术实现方案` 为空或明显不完整,进入 [plan](./stages.md#plan)。\n10. 进入 [tasks](./stages.md#tasks) 生成或刷新详细任务清单。\n\n> 章节名 `## 待确认项` 为新名,parser/lint 兼容旧名 `## 待确认问题`;存量 spec 保留旧名仍可识别,新建/重写一律用 `待确认项`。\n>\n> 待确认项判定:章节内存在任一 `### ` 三级标题即视为未决;仅有 `_暂无_` 或整章为空即视为无未决条目;存在未决条目时禁止进入 execute。\n>\n> `done` 判定忽略 `- [ ] [manual]` 人工确认项:即使 manual 项仍未勾选,只要其余任务全部完成、且无待确认项/批注/`[open]`,即可收尾为 `done`。\n\n## 全局硬约束\n\n### 通用\n\n- 仅更新目标 spec 文档,不修改无关文档。\n- 每次写回文档都必须更新 frontmatter 元信息。\n- 执行任务时若涉及代码改动,只改动与任务相关的最小范围。\n\n### 章节建议\n\n初始化 spec 时**建议**创建以下章节(`## 追加任务` `## 用户批注` 为可选,由用户触发追加时懒插入):\n\n- `## 现状分析` / `## 技术实现方案` / `## 待确认项` / `## 任务清单` / `## 执行记录`\n\n> lint 不再强制校验章节齐全;但 routing 按章节名查找时,章节不存在视为空态。\n\n### 变更重开流程\n\n**触发条件:**\n\n- 在已有 spec 中识别到"新增/扩展需求"或"新增 bug",将 `stage` 切回 `plan`,`last_action` 记录"变更重开流程"。\n- `## 追加任务` 存在 `- [open] [feat|refct|fix] ...` 条目时,将 `stage` 切回 `plan`。\n\n**重开后必须做的事:**\n\n- 仅针对新增内容重新走完 `plan → tasks → execute`。\n- 未受影响的既有结论/任务可保留,受影响任务必须重新评估。\n- plan 阶段消费追加任务条目时,**不修改** `[open]` 状态标记。\n\n## 输出优先级\n\n1. 先保证状态正确(frontmatter 元信息更新)。\n2. 再保证结构正确(列表格式、任务格式、候选项)。\n3. 最后保证内容质量(可执行、可验收、可追踪)。\n\n## 写回后的 lint 硬约束\n\n任何阶段完成对 `spec.md` / `review.md` 的写入后,Agent **必须**通过 Bash 运行 `yorz lint <path> --format json`,并 parse stdout。\n\n- `errorCount === 0` 时视为通过。\n- 存在 `severity: error` 时按 `ruleId` + `message` + `line` 定位并修改,然后重新运行 lint。\n- 同一文件 lint 连续失败达到 **3 次**,将偏差作为 `## 待确认项` 条目写入 spec.md 后退出,等待人工干预。\n- `warn` 级 finding 不阻断推进,但应尽力消除。\n\n## 持续推进硬约束\n\nAgent 在 plan / tasks / execute 任意阶段,**只允许**因以下"合法阻塞"退出当轮:\n\n1. `## 待确认项` 章节存在非 `暂无` 条目;\n2. spec md 内存在任何 `!!!` 批注;\n3. 识别到新增/扩展需求或新增 bug,按变更重开流程切回 `plan`;\n4. 执行外部命令 / 工具调用失败,需要用户做不可推断的决断。\n\n**显式禁止**以下"元确认"行为:\n\n- 以"任务很多 / 改动较大 / 范围广 / 时间长 / 风险高"为由询问"是否继续 / 是否执行 / 是否同意此方案"。\n- 在 execute 阶段中段汇报进度并要求用户确认下一步。\n- 反问类如"要不要我开始 / 是否继续下一项"等问句。\n\n替代约定:tasks/execute 阶段若产生新疑问,**必须**作为新条目写回 `## 待确认项` 并按变更重开流程退出。\n\n兜底:对外部世界有副作用、不可逆的命令(如 `git push`、`git reset --hard`、`rm -rf`、修改 CI 配置等破坏性操作)仍按系统默认安全准则处理。\n\n## 与 YorZ 工作流的关联\n\n- 本 skill 不依赖 YorZ Service / GUI 也能独立工作:Agent 直接读写 `spec_path` 即可。\n- 当 YorZ Service 在线时,Agent 写回 md 会被 Service 的 FS Watcher 感知并推送给 GUI。\n- 如需用户介入,Agent 把信息写回文档对应章节后立即退出,由用户人工补齐 `!!!` 批注,再由 CLI / Service 重新拉起 Agent 继续执行。\n';
61
89
  const __vite_glob_0_3 = "---\nstage: execute\nlast_action: 主任务完成,追加 1 项 [open] 待处理\nupdated_at: 2026-06-20\nsummary: HELLO.txt 已经创建;追加任务:在 HELLO.txt 末尾追加一行 `goodbye world\\n`。\n---\n\n# HELLO 占位\n\n## 1. 背景\n\n希望仓库根有占位文件,便于演示。\n\n## 2. 需求\n\n- 在仓库根创建 `HELLO.txt`,内容 `hello world\\n`\n\n## 3. 现状分析\n\n`HELLO.txt` 已经存在,内容 `hello world\\n`。\n\n## 4. 技术实现方案\n\n- 直接写入 `HELLO.txt`,内容 `hello world\\n`。\n\n## 5. 待确认问题\n\n- 暂无\n\n## 6. 任务清单\n\n- [x] 在仓库根新建 `HELLO.txt`,内容 `hello world\\n`,验收点:文件存在且字节数 = 12。\n\n## 7. 追加任务\n\n- [open] 在 `HELLO.txt` 末尾追加一行 `goodbye world\\n`,验收点:最终文件含 2 行、共 26 字节。\n\n## 8. 执行记录\n\n- 2026-06-20 创建 `HELLO.txt`,内容 `hello world\\n`。\n";
@@ -167,15 +195,15 @@ const SKILL_FILES = /* @__PURE__ */ Object.assign({
167
195
  const SKILL_ROOT = "../skill/";
168
196
  function resolveSkillFiles(skillDir = SKILL_DIR_NAME) {
169
197
  const prefix = `${SKILL_ROOT}${skillDir}/`;
170
- const files = [];
198
+ const files2 = [];
171
199
  for (const [key, content] of Object.entries(SKILL_FILES)) {
172
200
  const idx = key.indexOf(prefix);
173
201
  if (idx < 0) continue;
174
202
  const relPath = key.slice(idx + prefix.length);
175
203
  if (!relPath || relPath.startsWith("__tests__/") || relPath.includes("/__tests__/")) continue;
176
- files.push({ relPath, content });
204
+ files2.push({ relPath, content });
177
205
  }
178
- return files.sort((a, b) => a.relPath.localeCompare(b.relPath));
206
+ return files2.sort((a, b) => a.relPath.localeCompare(b.relPath));
179
207
  }
180
208
  async function install(opts, skillName = SKILL_DIR_NAME) {
181
209
  const adapter = getAdapter(opts.agent);
@@ -185,11 +213,11 @@ async function install(opts, skillName = SKILL_DIR_NAME) {
185
213
  const overwritten = await dirExists(skillDir);
186
214
  await rm(skillDir, { recursive: true, force: true });
187
215
  await mkdir(skillDir, { recursive: true });
188
- const files = resolveSkillFiles(skillName);
189
- if (!files.some((f) => f.relPath === "SKILL.md")) {
216
+ const files2 = resolveSkillFiles(skillName);
217
+ if (!files2.some((f) => f.relPath === "SKILL.md")) {
190
218
  throw new Error(`install: bundled skill "${skillName}" missing SKILL.md entry`);
191
219
  }
192
- for (const f of files) {
220
+ for (const f of files2) {
193
221
  const target = join(skillDir, f.relPath);
194
222
  await mkdir(dirname(target), { recursive: true });
195
223
  await writeFile(target, f.content, "utf8");
@@ -198,7 +226,7 @@ async function install(opts, skillName = SKILL_DIR_NAME) {
198
226
  return {
199
227
  path: entry,
200
228
  overwritten,
201
- files: files.map((f) => f.relPath),
229
+ files: files2.map((f) => f.relPath),
202
230
  gitignore
203
231
  };
204
232
  }
@@ -208,7 +236,7 @@ function computeBundledFingerprint(skillName = SKILL_DIR_NAME) {
208
236
  async function readInstalledFingerprint(skillDir, skillName = SKILL_DIR_NAME) {
209
237
  const relPaths = resolveSkillFiles(skillName).map((f) => f.relPath);
210
238
  if (!relPaths.includes("SKILL.md")) return null;
211
- const files = [];
239
+ const files2 = [];
212
240
  for (const relPath of relPaths) {
213
241
  let content;
214
242
  try {
@@ -217,13 +245,13 @@ async function readInstalledFingerprint(skillDir, skillName = SKILL_DIR_NAME) {
217
245
  if (relPath === "SKILL.md") return null;
218
246
  content = "";
219
247
  }
220
- files.push({ relPath, content });
248
+ files2.push({ relPath, content });
221
249
  }
222
- return fingerprintFiles(files);
250
+ return fingerprintFiles(files2);
223
251
  }
224
- function fingerprintFiles(files) {
252
+ function fingerprintFiles(files2) {
225
253
  const hash = createHash("sha256");
226
- for (const f of [...files].sort((a, b) => a.relPath.localeCompare(b.relPath))) {
254
+ for (const f of [...files2].sort((a, b) => a.relPath.localeCompare(b.relPath))) {
227
255
  hash.update(f.relPath);
228
256
  hash.update("\0");
229
257
  hash.update(f.content);
@@ -482,13 +510,13 @@ class AttachmentStore {
482
510
  if (!existsSync(dir)) return [];
483
511
  const names = await this.listAttachmentNames(draftId);
484
512
  const out = [];
485
- for (const name of names) {
486
- const stats = await stat(join(dir, name));
487
- const ext = extname(name).toLowerCase();
513
+ for (const name2 of names) {
514
+ const stats = await stat(join(dir, name2));
515
+ const ext = extname(name2).toLowerCase();
488
516
  const mime = mimeForExt(ext);
489
517
  const kind = classifyMime(mime);
490
518
  if (!kind) continue;
491
- out.push({ storedName: name, name, size: stats.size, mime, kind });
519
+ out.push({ storedName: name2, name: name2, size: stats.size, mime, kind });
492
520
  }
493
521
  return out.sort((a, b) => a.storedName < b.storedName ? -1 : 1);
494
522
  }
@@ -555,8 +583,8 @@ class AttachmentStore {
555
583
  return uniquify(`${sanitized}${ext}`, existing);
556
584
  }
557
585
  }
558
- function isPlaceholderImageName(name) {
559
- const lower = name.toLowerCase().trim();
586
+ function isPlaceholderImageName(name2) {
587
+ const lower = name2.toLowerCase().trim();
560
588
  if (!lower) return true;
561
589
  return lower === "image.png" || lower === "image.jpg" || lower === "image.jpeg" || lower === "image.webp" || lower === "image.gif" || /^(image|screenshot|clipboard)([._ -]?\d+)?\.[a-z0-9]+$/.test(lower) || /^untitled\.[a-z0-9]+$/.test(lower);
562
590
  }
@@ -565,481 +593,391 @@ function pickExt(rawName, mime) {
565
593
  if (fromName) return fromName;
566
594
  return MIME_TO_EXT[mime] ?? "";
567
595
  }
568
- function stripExt(name) {
569
- const ext = extname(name);
570
- return ext ? name.slice(0, name.length - ext.length) : name;
596
+ function stripExt(name2) {
597
+ const ext = extname(name2);
598
+ return ext ? name2.slice(0, name2.length - ext.length) : name2;
571
599
  }
572
600
  function sanitize(text) {
573
601
  let cleaned = text.replace(/[\s\\/:*?"<>|\x00-\x1f]+/g, "-");
574
602
  cleaned = cleaned.replace(/\.+/g, "-");
575
603
  return cleaned.replace(/^-+|-+$/g, "").slice(0, 64);
576
604
  }
577
- function uniquify(name, existing) {
578
- if (!existing.includes(name)) return name;
579
- const ext = extname(name);
580
- const base = stripExt(name);
605
+ function uniquify(name2, existing) {
606
+ if (!existing.includes(name2)) return name2;
607
+ const ext = extname(name2);
608
+ const base = stripExt(name2);
581
609
  for (let i = 1; i < 1e3; i++) {
582
610
  const candidate = `${base}-${i}${ext}`;
583
611
  if (!existing.includes(candidate)) return candidate;
584
612
  }
585
613
  return `${base}-${randomUUID().slice(0, 4)}${ext}`;
586
614
  }
587
- function isSafeName(name) {
588
- if (!name) return false;
589
- if (name.includes("/") || name.includes("\\")) return false;
590
- if (name === "." || name === "..") return false;
591
- if (name.startsWith(".")) return false;
615
+ function isSafeName(name2) {
616
+ if (!name2) return false;
617
+ if (name2.includes("/") || name2.includes("\\")) return false;
618
+ if (name2 === "." || name2 === "..") return false;
619
+ if (name2.startsWith(".")) return false;
592
620
  return true;
593
621
  }
594
- function createSpecsRoutes(resolveProject) {
595
- const app = new Hono();
596
- const need = async (c) => {
597
- const id = c.req.param("projectId") ?? "";
598
- if (!id) return c.json({ error: "projectId required" }, 400);
599
- const project = await resolveProject(id);
600
- if (!project) return c.json({ error: "project not found" }, 404);
601
- return project;
602
- };
603
- app.get("/projects/:projectId/specs", async (c) => {
604
- const p = await need(c);
605
- if (p instanceof Response) return p;
606
- const items = await p.store.list();
607
- return c.json(items);
608
- });
609
- app.post("/projects/:projectId/specs", async (c) => {
610
- const p = await need(c);
611
- if (p instanceof Response) return p;
612
- let body;
613
- try {
614
- body = await c.req.json();
615
- } catch {
616
- return c.json({ error: "invalid JSON body" }, 400);
617
- }
618
- const input = parseCreateBody(body);
619
- if ("error" in input) return c.json({ error: input.error }, 400);
620
- if (!input.title && input.requirement) {
621
- const prompt = buildDraftPrompt(input.type, input.requirement, input.draftId);
622
- const { sessionId } = await p.sessions.createSession();
623
- const handle = await p.sessions.send(sessionId, prompt);
624
- return c.json({ runId: handle.runId, sessionId, draft: true }, 202);
625
- }
626
- try {
627
- const { id, path } = await p.store.create(input);
628
- return c.json({ id, path }, 201);
629
- } catch (err) {
630
- return c.json({ error: err.message }, 400);
631
- }
632
- });
633
- app.get("/projects/:projectId/specs/:id", async (c) => {
634
- const p = await need(c);
635
- if (p instanceof Response) return p;
636
- const detail = await p.store.read(c.req.param("id"));
637
- if (!detail) return c.json({ error: "spec not found" }, 404);
638
- return c.json(detail);
639
- });
640
- app.patch("/projects/:projectId/specs/:id/stage", async (c) => {
641
- const p = await need(c);
642
- if (p instanceof Response) return p;
643
- const specId = c.req.param("id");
644
- let body;
645
- try {
646
- body = await c.req.json();
647
- } catch {
648
- return c.json({ error: "invalid JSON body" }, 400);
649
- }
650
- const stage = body && typeof body === "object" ? body.stage : void 0;
651
- if (stage !== "plan" && stage !== "tasks" && stage !== "execute" && stage !== "done") {
652
- return c.json({ error: "stage must be plan | tasks | execute | done" }, 400);
653
- }
654
- try {
655
- await p.store.setStage(specId, stage);
656
- return c.json({ ok: true });
657
- } catch (err) {
658
- return c.json({ error: err.message }, 404);
659
- }
660
- });
661
- app.get("/projects/:projectId/specs/:id/attachments/:name", async (c) => {
662
- const p = await need(c);
663
- if (p instanceof Response) return p;
664
- const id = c.req.param("id");
665
- const name = c.req.param("name");
666
- if (!isSafeAttachmentName(name)) {
667
- return c.json({ error: "invalid attachment name" }, 400);
668
- }
669
- const detail = await p.store.read(id);
670
- if (!detail) return c.json({ error: "spec not found" }, 404);
671
- const file = join(p.specsDir, id, "attachments", name);
672
- if (!existsSync(file)) return c.json({ error: "attachment not found" }, 404);
673
- const ext = extname(name).toLowerCase();
674
- const mime = mimeForExt(ext);
675
- const kind = classifyMime(mime);
676
- const data = await readFile(file);
677
- c.header("Content-Type", mime);
678
- c.header(
679
- "Content-Disposition",
680
- kind === "image" || kind === "pdf" || kind === "text" ? "inline" : "attachment"
681
- );
682
- c.header("Cache-Control", "max-age=300");
683
- return c.body(new Uint8Array(data));
684
- });
685
- app.post("/projects/:projectId/specs/:id/inputs", async (c) => {
686
- const p = await need(c);
687
- if (p instanceof Response) return p;
688
- let body;
689
- try {
690
- body = await c.req.json();
691
- } catch {
692
- return c.json({ error: "invalid JSON body" }, 400);
693
- }
694
- const parsed = parseAnnotateBody(body);
695
- if ("error" in parsed) return c.json({ error: parsed.error }, 400);
696
- try {
697
- await p.store.appendAnnotation(c.req.param("id"), parsed);
698
- return c.json({ ok: true });
699
- } catch (err) {
700
- return c.json({ error: err.message }, 404);
701
- }
702
- });
703
- app.post("/projects/:projectId/specs/:id/questions/answers", async (c) => {
704
- const p = await need(c);
705
- if (p instanceof Response) return p;
706
- let body;
707
- try {
708
- body = await c.req.json();
709
- } catch {
710
- return c.json({ error: "invalid JSON body" }, 400);
711
- }
712
- const parsed = parseQuestionAnswersBody(body);
713
- if ("error" in parsed) return c.json({ error: parsed.error }, 400);
714
- try {
715
- await p.store.applyQuestionAnswers(c.req.param("id"), parsed);
716
- return c.json({ ok: true });
717
- } catch (err) {
718
- const msg = err.message;
719
- const status = /spec not found/.test(msg) ? 404 : 400;
720
- return c.json({ error: msg }, status);
721
- }
722
- });
723
- app.post("/projects/:projectId/specs/:id/appends", async (c) => {
724
- const p = await need(c);
725
- if (p instanceof Response) return p;
726
- const specId = c.req.param("id");
727
- let body;
728
- try {
729
- body = await c.req.json();
730
- } catch {
731
- return c.json({ error: "invalid JSON body" }, 400);
732
- }
733
- const parsed = parseAppendBody(body);
734
- if ("error" in parsed) return c.json({ error: parsed.error }, 400);
735
- const detail = await p.store.read(specId);
736
- if (!detail) return c.json({ error: "spec not found" }, 404);
737
- try {
738
- await p.store.appendItem(specId, {
739
- kind: parsed.kind,
740
- description: parsed.description,
741
- sectionPath: parsed.sectionPath,
742
- quote: parsed.quote
743
- });
744
- } catch (err) {
745
- return c.json({ error: err.message }, 400);
746
- }
747
- if (parsed.autoRun) {
748
- const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
749
- const debugActive = await readDebugMdStatus(join(p.specsDir, specId)) === "debugging";
750
- const prompt = parsed.debug ? buildDebugPrompt(p.specsDirRelative, specId, "new", await buildDebugRuntimeContext(p)) : debugActive ? buildDebugPrompt(
751
- p.specsDirRelative,
752
- specId,
753
- "resume",
754
- await buildDebugRuntimeContext(p)
755
- ) : `请使用 yorz-spec skill 处理 spec:${p.specsDirRelative}/${specId}/spec.md`;
756
- const handle = await p.sessions.send(sessionId, prompt);
757
- return c.json({ ok: true, runId: handle.runId, sessionId });
758
- }
759
- return c.json({ ok: true });
760
- });
761
- app.post("/projects/:projectId/specs/:id/run", async (c) => {
762
- const p = await need(c);
763
- if (p instanceof Response) return p;
764
- const specId = c.req.param("id");
765
- const detail = await p.store.read(specId);
766
- if (!detail) return c.json({ error: "spec not found" }, 404);
767
- const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
768
- const debugActive = await readDebugMdStatus(join(p.specsDir, specId)) === "debugging";
769
- const prompt = debugActive ? buildDebugPrompt(p.specsDirRelative, specId, "resume", await buildDebugRuntimeContext(p)) : `请使用 yorz-spec skill 处理 spec:${p.specsDirRelative}/${specId}/spec.md`;
770
- const handle = await p.sessions.send(sessionId, prompt);
771
- return c.json({ runId: handle.runId, sessionId });
772
- });
773
- app.post("/projects/:projectId/specs/:id/explain", async (c) => {
774
- const p = await need(c);
775
- if (p instanceof Response) return p;
776
- const specId = c.req.param("id");
777
- let body;
778
- try {
779
- body = await c.req.json();
780
- } catch {
781
- return c.json({ error: "invalid JSON body" }, 400);
782
- }
783
- const text = body && typeof body === "object" ? body.text : void 0;
784
- if (typeof text !== "string" || !text.trim()) {
785
- return c.json({ error: "text required" }, 400);
786
- }
787
- if (text.length > 4e3) {
788
- return c.json({ error: "text too long (max 4000)" }, 400);
789
- }
790
- const detail = await p.store.read(specId);
791
- if (!detail) return c.json({ error: "spec not found" }, 404);
792
- const prompt = `以下为 spec 文档 ${p.specsDirRelative}/${specId}/spec.md 中的一段内容。
793
- 请用中文简洁解释其含义、背景与可能的实施影响。**不要**修改任何文件,只在终端输出解释文本。
794
-
795
- 引用:
796
- """
797
- ${text}
798
- """
799
- `;
800
- const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
801
- const handle = await p.sessions.send(sessionId, prompt);
802
- return c.json({ runId: handle.runId, sessionId });
803
- });
804
- app.delete("/projects/:projectId/specs/:id", async (c) => {
805
- const p = await need(c);
806
- if (p instanceof Response) return p;
807
- const specId = c.req.param("id");
808
- const detail = await p.store.read(specId);
809
- if (!detail) return c.json({ error: "spec not found" }, 404);
810
- await p.store.delete(specId);
811
- return c.json({ ok: true });
812
- });
813
- return app;
622
+ const CURRENT_VERSION$1 = 1;
623
+ const DEFAULT_GLOBAL_AGENT = { defaultKind: "claude" };
624
+ const DEFAULT_NOTIFICATIONS = {
625
+ sessionEnd: { banner: false, sound: false }
626
+ };
627
+ function resolveGlobalConfigDir(env = process.env) {
628
+ if (env.YORZ_HOME && env.YORZ_HOME.trim()) return env.YORZ_HOME.trim();
629
+ const xdg = env.XDG_CONFIG_HOME?.trim();
630
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".config");
631
+ return join(base, "yorz");
814
632
  }
815
- function buildDraftPrompt(type, requirement, draftId) {
816
- const lines = [
817
- "请按 yorz-spec skill 的「新建 spec」流程,根据下方信息创建新的 spec 文档,并立即按 plan 阶段继续推进直至阻塞。",
818
- "",
819
- `类型:${type}(已由调用方指定,不要再询问)`,
820
- "需求:",
821
- '"""',
822
- requirement.trim(),
823
- '"""',
824
- "",
825
- "硬性要求:",
826
- `- 生成 kebab-case summary-name 时只使用对需求有语义代表性的英文/数字字符;如难以从中文中提炼出可读 slug,请直接使用 \`untitled-\` + 3 位日期内自增编号占位,禁止把中文挤压为零散英文片段(例如禁止出现 \`spec-agent-spec-agent\` 之类的拼接)。`,
827
- "- frontmatter.summary 必须是对需求的真实概述(≤200 字符),不要原样照搬整段需求。",
828
- "- 完成 spec 文件初始化后立即进入 plan 阶段,按 SKILL 规则补齐 `现状分析` / `技术实现方案` / `待确认项`,再视需要进入 tasks/execute。"
829
- ];
830
- if (draftId) {
831
- lines.push(
832
- "",
833
- `附件迁移:本次新建 spec 关联了草稿附件目录 \`.yorz/tmp/drafts/${draftId}/attachments/\`。`,
834
- "- 在创建 `.yorz/specs/<id>/` 目录并写入 `spec.md` 骨架**之后**,立即把该 draft 目录下的所有文件迁移到 `.yorz/specs/<id>/attachments/`,文件名保持不变。",
835
- "- 迁移完成后,在 `## 背景` 章节末尾追加一段附件列表,每个附件占一行(按文件扩展名判定 `kind`):",
836
- " - 图片(`.png` / `.jpg` / `.jpeg` / `.gif` / `.webp` / `.bmp` / `.svg` / `.avif` / `.heic`):使用 `![<文件名>](attachments/<文件名>)`",
837
- " - PDF(`.pdf`) / 文本(`.txt` / `.md` / `.markdown`):使用 `[<文件名>](attachments/<文件名>)`",
838
- "- 迁移失败(如 draft 目录已被清理、权限不足)时,**不要静默丢弃**:在 `## 待确认项` 章节追加一条记录说明问题,并退出本轮等待用户介入。"
839
- );
840
- }
841
- return lines.join("\n");
633
+ function resolveGlobalConfigPath(env = process.env) {
634
+ return join(resolveGlobalConfigDir(env), "projects.json");
842
635
  }
843
- async function readDebugMdStatus(specDir) {
844
- const file = join(specDir, "debug.md");
845
- let text;
636
+ async function loadGlobalConfig(filePath) {
637
+ const fp = filePath ?? resolveGlobalConfigPath();
638
+ if (!existsSync(fp)) return defaultGlobalConfig();
639
+ let raw;
846
640
  try {
847
- text = await readFile(file, "utf8");
641
+ raw = await readFile(fp, "utf8");
848
642
  } catch {
849
- return null;
643
+ return defaultGlobalConfig();
850
644
  }
851
- const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
852
- if (!m) return null;
853
- const statusLine = m[1].split(/\r?\n/).find((l) => /^status\s*:/.test(l.trim()));
854
- if (!statusLine) return null;
855
- const val = statusLine.slice(statusLine.indexOf(":") + 1).trim().replace(/^['"]|['"]$/g, "");
856
- return val === "debugging" ? "debugging" : val === "resolved" ? "resolved" : null;
645
+ if (!raw.trim()) return defaultGlobalConfig();
646
+ let parsed;
647
+ try {
648
+ parsed = JSON.parse(raw);
649
+ } catch {
650
+ return defaultGlobalConfig();
651
+ }
652
+ return normalizeConfig$1(parsed);
857
653
  }
858
- function buildDebugPrompt(specsDirRelative, specId, mode, runtimeContext = "") {
859
- const specPath = `${specsDirRelative}/${specId}/spec.md`;
860
- const debugPath = `${specsDirRelative}/${specId}/debug.md`;
861
- if (mode === "resume") {
862
- return `请使用 yorz-debug skill 继续调试:spec 目录 ${specsDirRelative}/${specId}(含 ${specPath})。该目录下已存在处于 debugging 状态的 ${debugPath},请定位其活跃记录块(frontmatter active 指向的 ## Debug NNN)继续「假设 → 取证 → 验证」循环,勿新建记录块。` + runtimeContext;
654
+ async function saveGlobalConfig(config, filePath) {
655
+ const fp = filePath ?? resolveGlobalConfigPath();
656
+ await mkdir(dirname(fp), { recursive: true });
657
+ const normalized = normalizeConfig$1(config);
658
+ const body = `${JSON.stringify(normalized, null, 2)}
659
+ `;
660
+ const tmp = `${fp}.tmp-${process.pid}-${Date.now().toString(36)}`;
661
+ await writeFile(tmp, body, "utf8");
662
+ await rename(tmp, fp);
663
+ }
664
+ function normalizeConfig$1(value) {
665
+ if (!value || typeof value !== "object") return defaultGlobalConfig();
666
+ const obj = value;
667
+ const projectsRaw = Array.isArray(obj.projects) ? obj.projects : [];
668
+ const projects = [];
669
+ for (const item of projectsRaw) {
670
+ if (!item || typeof item !== "object") continue;
671
+ const it = item;
672
+ const id = typeof it.id === "string" ? it.id : "";
673
+ const path = typeof it.path === "string" ? it.path : "";
674
+ if (!id || !path) continue;
675
+ const addedAt = typeof it.addedAt === "string" ? it.addedAt : "";
676
+ const lastActivityAt = typeof it.lastActivityAt === "string" ? it.lastActivityAt : null;
677
+ const worktree = normalizeWorktree(it.worktree);
678
+ const entry = { id, path, addedAt, lastActivityAt };
679
+ if (worktree) entry.worktree = worktree;
680
+ projects.push(entry);
863
681
  }
864
- return `请使用 yorz-debug skill 进入 Debug 模式:spec 目录 ${specsDirRelative}/${specId}(含 ${specPath})。若 ${debugPath} 不存在则创建,否则在文末追加新的 ## Debug 记录块;进入后立即 git stash create 打快照写入 Debug 基线,再开始「假设 → 取证 → 验证」循环。` + runtimeContext;
682
+ return {
683
+ version: CURRENT_VERSION$1,
684
+ projects,
685
+ agent: normalizeAgent$1(obj.agent),
686
+ notifications: normalizeNotifications(obj.notifications)
687
+ };
865
688
  }
866
- async function buildDebugRuntimeContext(project) {
867
- return formatDebugRuntimeContext(await project.commands.listRuns());
689
+ function defaultGlobalConfig() {
690
+ return {
691
+ version: CURRENT_VERSION$1,
692
+ projects: [],
693
+ agent: { defaultKind: DEFAULT_GLOBAL_AGENT.defaultKind },
694
+ notifications: {
695
+ sessionEnd: {
696
+ banner: DEFAULT_NOTIFICATIONS.sessionEnd.banner,
697
+ sound: DEFAULT_NOTIFICATIONS.sessionEnd.sound
698
+ }
699
+ }
700
+ };
868
701
  }
869
- function formatDebugRuntimeContext(runs) {
870
- const running = runs.filter((r) => r.status === "running");
871
- if (running.length === 0) {
872
- return "\n\n当前项目运行服务上下文:暂无运行中的命令服务。若复现需要服务,请根据项目脚本自行启动,或请用户在 GUI 命令菜单启动后重试。";
702
+ function normalizeAgent$1(value) {
703
+ if (!value || typeof value !== "object") return { defaultKind: DEFAULT_GLOBAL_AGENT.defaultKind };
704
+ const obj = value;
705
+ const defaultKind = obj.defaultKind;
706
+ if (defaultKind === "opencode" || defaultKind === "codex") return { defaultKind };
707
+ return { defaultKind: "claude" };
708
+ }
709
+ function normalizeNotifications(value) {
710
+ if (!value || typeof value !== "object") return defaultGlobalConfig().notifications;
711
+ const obj = value;
712
+ const sessionEndRaw = obj.sessionEnd;
713
+ if (!sessionEndRaw || typeof sessionEndRaw !== "object")
714
+ return defaultGlobalConfig().notifications;
715
+ const sessionEndObj = sessionEndRaw;
716
+ return {
717
+ sessionEnd: {
718
+ banner: typeof sessionEndObj.banner === "boolean" ? sessionEndObj.banner : DEFAULT_NOTIFICATIONS.sessionEnd.banner,
719
+ sound: typeof sessionEndObj.sound === "boolean" ? sessionEndObj.sound : DEFAULT_NOTIFICATIONS.sessionEnd.sound
720
+ }
721
+ };
722
+ }
723
+ function normalizeWorktree(value) {
724
+ if (!value || typeof value !== "object") return void 0;
725
+ const it = value;
726
+ const mainProjectId = typeof it.mainProjectId === "string" ? it.mainProjectId : "";
727
+ const mainPath = typeof it.mainPath === "string" ? it.mainPath : "";
728
+ const branch = typeof it.branch === "string" ? it.branch : "";
729
+ const specId = typeof it.specId === "string" ? it.specId : "";
730
+ const createdAt = typeof it.createdAt === "string" ? it.createdAt : "";
731
+ if (!mainProjectId || !mainPath || !branch) return void 0;
732
+ const cleanSlug = typeof it.cleanSlug === "string" ? it.cleanSlug : void 0;
733
+ const meta = { mainProjectId, mainPath, branch, specId, createdAt };
734
+ if (cleanSlug) meta.cleanSlug = cleanSlug;
735
+ return meta;
736
+ }
737
+ function generateProjectId(absPath) {
738
+ const base = basename$1(absPath);
739
+ const slug = slugify(base) || "proj";
740
+ const hash = createHash("sha256").update(absPath).digest("hex").slice(0, 6);
741
+ return `${slug}-${hash}`;
742
+ }
743
+ function slugify(text) {
744
+ return text.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
745
+ }
746
+ async function addProject(absPath, filePath, now = () => /* @__PURE__ */ new Date()) {
747
+ const config = await loadGlobalConfig(filePath);
748
+ const existing = config.projects.find((p) => p.path === absPath);
749
+ if (existing) return { entry: existing, created: false };
750
+ const entry = {
751
+ id: generateProjectId(absPath),
752
+ path: absPath,
753
+ addedAt: now().toISOString(),
754
+ lastActivityAt: null
755
+ };
756
+ config.projects.push(entry);
757
+ await saveGlobalConfig(config, filePath);
758
+ return { entry, created: true };
759
+ }
760
+ async function setProjectWorktree(id, worktree, filePath) {
761
+ const config = await loadGlobalConfig(filePath);
762
+ const target = config.projects.find((p) => p.id === id);
763
+ if (!target) return false;
764
+ if (worktree) target.worktree = worktree;
765
+ else delete target.worktree;
766
+ await saveGlobalConfig(config, filePath);
767
+ return true;
768
+ }
769
+ async function removeProject(id, filePath) {
770
+ const config = await loadGlobalConfig(filePath);
771
+ const before = config.projects.length;
772
+ config.projects = config.projects.filter((p) => p.id !== id);
773
+ if (config.projects.length === before) return false;
774
+ await saveGlobalConfig(config, filePath);
775
+ return true;
776
+ }
777
+ async function prepareProjectDir(input, cwd) {
778
+ if (typeof input !== "string" || !input.trim()) {
779
+ throw new Error("path required");
780
+ }
781
+ const trimmed = input.trim();
782
+ const abs = isAbsolute(trimmed) ? resolve(trimmed) : resolve(cwd ?? process.cwd(), trimmed);
783
+ if (!existsSync(abs)) {
784
+ throw new Error(`path does not exist: ${abs}`);
785
+ }
786
+ const stats = await stat(abs);
787
+ if (!stats.isDirectory()) {
788
+ throw new Error(`path is not a directory: ${abs}`);
789
+ }
790
+ await mkdir(join(abs, ".yorz", "specs"), { recursive: true });
791
+ return abs;
792
+ }
793
+ const LEVEL_WEIGHT = {
794
+ debug: 10,
795
+ info: 20,
796
+ warn: 30,
797
+ error: 40
798
+ };
799
+ const LOG_LEVELS = Object.keys(LEVEL_WEIGHT);
800
+ const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
801
+ const DEFAULT_MAX_ARCHIVES = 1;
802
+ const DEFAULT_LOG_FILE = "serve.log";
803
+ const STDIO_LOG_FILE = "serve-stdio.log";
804
+ function resolveLogDir(env = process.env) {
805
+ return join(resolveGlobalConfigDir(env), "logs");
806
+ }
807
+ function resolveLogLevel(env = process.env) {
808
+ const raw = env.YORZ_LOG_LEVEL?.trim().toLowerCase();
809
+ if (raw && LOG_LEVELS.includes(raw)) return raw;
810
+ return "info";
811
+ }
812
+ function defaultOptions(env = process.env) {
813
+ return {
814
+ dir: resolveLogDir(env),
815
+ fileName: DEFAULT_LOG_FILE,
816
+ maxBytes: DEFAULT_MAX_BYTES,
817
+ maxArchives: DEFAULT_MAX_ARCHIVES,
818
+ level: resolveLogLevel(env),
819
+ mirrorConsole: true
820
+ };
821
+ }
822
+ class RotatingFileSink {
823
+ constructor(dir, fileName, maxBytes, maxArchives) {
824
+ this.dir = dir;
825
+ this.fileName = fileName;
826
+ this.maxBytes = maxBytes;
827
+ this.maxArchives = maxArchives;
828
+ }
829
+ currentSize = 0;
830
+ initialized = false;
831
+ queue = Promise.resolve();
832
+ get filePath() {
833
+ return join(this.dir, this.fileName);
834
+ }
835
+ archivePath(index) {
836
+ return `${this.filePath}.${index}`;
837
+ }
838
+ /** Enqueue a line; returns immediately, never throws. */
839
+ write(line) {
840
+ this.queue = this.queue.then(() => this.writeNow(line)).catch(() => {
841
+ });
873
842
  }
874
- const lines = ["\n\n当前项目运行服务上下文:"];
875
- for (const run of running) {
876
- lines.push(
877
- `- runId: ${promptScalar(run.runId)}`,
878
- ` name: ${promptScalar(run.name)}`,
879
- ` cli: ${promptScalar(run.cli)}`,
880
- ` status: ${run.status}`,
881
- ` pid: ${run.pid}`,
882
- ` startedAt: ${new Date(run.startedAt).toISOString()}`,
883
- ` logFile: ${promptScalar(run.logFile)}`
884
- );
843
+ /** Resolve once every queued line has been flushed to disk. */
844
+ async flush() {
845
+ await this.queue.catch(() => {
846
+ });
885
847
  }
886
- return lines.join("\n");
887
- }
888
- function promptScalar(value) {
889
- return value.replace(/\r?\n/g, "\\n");
890
- }
891
- function parseCreateBody(body) {
892
- if (!body || typeof body !== "object") return { error: "body must be an object" };
893
- const obj = body;
894
- const rawType = obj.type ?? "feat";
895
- if (rawType !== "feat" && rawType !== "refct" && rawType !== "fix") {
896
- return { error: "type must be feat | refct | fix" };
848
+ async writeNow(line) {
849
+ try {
850
+ await this.ensureInit();
851
+ const bytes = Buffer.byteLength(line, "utf8");
852
+ if (this.currentSize > 0 && this.currentSize + bytes > this.maxBytes) {
853
+ await this.rotate();
854
+ }
855
+ await appendFile(this.filePath, line, "utf8");
856
+ this.currentSize += bytes;
857
+ } catch {
858
+ }
897
859
  }
898
- const out = { type: rawType };
899
- if (typeof obj.title === "string" && obj.title.trim()) out.title = obj.title;
900
- if (typeof obj.summary === "string" && obj.summary.trim()) out.summary = obj.summary;
901
- if (typeof obj.requirement === "string" && obj.requirement.trim()) {
902
- out.requirement = obj.requirement;
860
+ /** Lazily create the dir and recover `currentSize` from an existing file. */
861
+ async ensureInit() {
862
+ if (this.initialized) return;
863
+ await mkdir(this.dir, { recursive: true });
864
+ try {
865
+ const st = await stat(this.filePath);
866
+ this.currentSize = st.size;
867
+ } catch {
868
+ this.currentSize = 0;
869
+ }
870
+ this.initialized = true;
903
871
  }
904
- if (obj.draftId !== void 0) {
905
- if (typeof obj.draftId !== "string" || !obj.draftId.trim()) {
906
- return { error: "draftId must be a non-empty string" };
872
+ async rotate() {
873
+ if (this.maxArchives <= 0) {
874
+ await writeFile(this.filePath, "", "utf8");
875
+ this.currentSize = 0;
876
+ return;
907
877
  }
908
- if (!/^[a-zA-Z0-9-]{1,64}$/.test(obj.draftId)) {
909
- return { error: "draftId has invalid format" };
878
+ for (let i = this.maxArchives - 1; i >= 1; i--) {
879
+ try {
880
+ await rename(this.archivePath(i), this.archivePath(i + 1));
881
+ } catch {
882
+ }
910
883
  }
911
- out.draftId = obj.draftId;
884
+ await rename(this.filePath, this.archivePath(1));
885
+ this.currentSize = 0;
912
886
  }
913
- return out;
914
887
  }
915
- function isSafeAttachmentName(name) {
916
- if (!name) return false;
917
- if (name.includes("/") || name.includes("\\")) return false;
918
- if (name === "." || name === "..") return false;
919
- if (name.startsWith(".")) return false;
920
- return true;
888
+ function createSink(options) {
889
+ return new RotatingFileSink(options.dir, options.fileName, options.maxBytes, options.maxArchives);
921
890
  }
922
- function parseQuestionAnswersBody(body) {
923
- if (!body || typeof body !== "object") return { error: "body must be an object" };
924
- const obj = body;
925
- const rawAnswers = obj.answers;
926
- const rawFreeforms = obj.freeformAnnotations;
927
- if (rawAnswers !== void 0 && !Array.isArray(rawAnswers)) {
928
- return { error: "answers must be an array" };
891
+ function formatMeta(meta) {
892
+ if (!meta) return "";
893
+ const keys = Object.keys(meta);
894
+ if (keys.length === 0) return "";
895
+ try {
896
+ return ` ${JSON.stringify(meta, jsonReplacer)}`;
897
+ } catch {
898
+ return "";
929
899
  }
930
- if (rawFreeforms !== void 0 && !Array.isArray(rawFreeforms)) {
931
- return { error: "freeformAnnotations must be an array" };
900
+ }
901
+ function jsonReplacer(_key, value) {
902
+ if (value instanceof Error) return { message: value.message, stack: value.stack };
903
+ if (typeof value === "bigint") return value.toString();
904
+ return value;
905
+ }
906
+ class Logger {
907
+ constructor(core, scope) {
908
+ this.core = core;
909
+ this.scope = scope;
932
910
  }
933
- const answers = [];
934
- for (const a of rawAnswers ?? []) {
935
- if (!a || typeof a !== "object") return { error: "answer must be an object" };
936
- const ao = a;
937
- if (typeof ao.questionText !== "string" || !ao.questionText.trim()) {
938
- return { error: "answer.questionText required" };
939
- }
940
- const item = { questionText: ao.questionText };
941
- if (typeof ao.questionId === "string") item.questionId = ao.questionId;
942
- if (typeof ao.selectedOptionLabel === "string" && ao.selectedOptionLabel.trim()) {
943
- item.selectedOptionLabel = ao.selectedOptionLabel;
944
- }
945
- if (typeof ao.note === "string" && ao.note.trim()) item.note = ao.note;
946
- if (!item.selectedOptionLabel && !item.note) {
947
- return { error: "answer requires selectedOptionLabel or note" };
948
- }
949
- answers.push(item);
911
+ get level() {
912
+ return this.core.options.level;
950
913
  }
951
- const freeformAnnotations = [];
952
- for (const f of rawFreeforms ?? []) {
953
- if (!f || typeof f !== "object") return { error: "freeformAnnotation must be an object" };
954
- const fo = f;
955
- if (typeof fo.sectionPath !== "string" || !fo.sectionPath.trim()) {
956
- return { error: "freeformAnnotation.sectionPath required" };
957
- }
958
- if (typeof fo.quote !== "string" || !fo.quote.trim()) {
959
- return { error: "freeformAnnotation.quote required" };
960
- }
961
- if (typeof fo.note !== "string" || !fo.note.trim()) {
962
- return { error: "freeformAnnotation.note required" };
963
- }
964
- freeformAnnotations.push({
965
- sectionPath: fo.sectionPath,
966
- quote: fo.quote,
967
- note: fo.note
968
- });
914
+ get filePath() {
915
+ return this.core.sink.filePath;
969
916
  }
970
- if (answers.length === 0 && freeformAnnotations.length === 0) {
971
- return { error: "answers or freeformAnnotations required" };
917
+ get dir() {
918
+ return this.core.options.dir;
972
919
  }
973
- return { answers, freeformAnnotations };
974
- }
975
- function parseAppendBody(body) {
976
- if (!body || typeof body !== "object") return { error: "body must be an object" };
977
- const obj = body;
978
- const kind = obj.kind;
979
- if (kind !== "feat" && kind !== "refct" && kind !== "fix") {
980
- return { error: "kind must be feat | refct | fix" };
920
+ /** Derive a logger that prefixes every line with `[scope]`. */
921
+ child(scope) {
922
+ const next = this.scope && this.scope !== "yorz" ? `${this.scope}:${scope}` : scope;
923
+ return new Logger(this.core, next);
981
924
  }
982
- if (typeof obj.description !== "string" || !obj.description.trim()) {
983
- return { error: "description required" };
925
+ debug(msg, meta) {
926
+ this.emit("debug", msg, meta);
984
927
  }
985
- const out = { kind, description: obj.description, autoRun: true, debug: false };
986
- if (obj.sectionPath !== void 0) {
987
- if (typeof obj.sectionPath !== "string") return { error: "sectionPath must be a string" };
988
- if (obj.sectionPath.length > 200) return { error: "sectionPath too long (max 200)" };
989
- if (obj.sectionPath.trim()) out.sectionPath = obj.sectionPath;
928
+ info(msg, meta) {
929
+ this.emit("info", msg, meta);
990
930
  }
991
- if (obj.quote !== void 0) {
992
- if (typeof obj.quote !== "string") return { error: "quote must be a string" };
993
- if (obj.quote.length > 500) return { error: "quote too long (max 500)" };
994
- if (obj.quote.trim()) out.quote = obj.quote;
931
+ warn(msg, meta) {
932
+ this.emit("warn", msg, meta);
995
933
  }
996
- if (obj.autoRun !== void 0) {
997
- if (typeof obj.autoRun !== "boolean") return { error: "autoRun must be a boolean" };
998
- out.autoRun = obj.autoRun;
934
+ error(msg, meta) {
935
+ this.emit("error", msg, meta);
999
936
  }
1000
- if (obj.debug !== void 0) {
1001
- if (typeof obj.debug !== "boolean") return { error: "debug must be a boolean" };
1002
- out.debug = obj.debug && kind === "fix";
937
+ /** Replace options in place; every existing child logger picks the change up. */
938
+ configure(patch) {
939
+ const prev = this.core.options;
940
+ const next = { ...prev, ...patch };
941
+ this.core.options = next;
942
+ const sinkChanged = next.dir !== prev.dir || next.fileName !== prev.fileName || next.maxBytes !== prev.maxBytes || next.maxArchives !== prev.maxArchives;
943
+ if (sinkChanged) this.core.sink = createSink(next);
1003
944
  }
1004
- return out;
1005
- }
1006
- function parseAnnotateBody(body) {
1007
- if (!body || typeof body !== "object") return { error: "body must be an object" };
1008
- const obj = body;
1009
- if (obj.kind !== "annotate") {
1010
- return { error: 'kind must be "annotate"' };
945
+ async flush() {
946
+ await this.core.sink.flush();
1011
947
  }
1012
- const sectionPath = obj.sectionPath;
1013
- const quote = obj.quote;
1014
- const note = obj.note;
1015
- if (typeof sectionPath !== "string" || !sectionPath.trim()) {
1016
- return { error: "sectionPath required" };
948
+ emit(level, msg, meta) {
949
+ const { options } = this.core;
950
+ if (LEVEL_WEIGHT[level] < LEVEL_WEIGHT[options.level]) return;
951
+ const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] [${level}] [${this.scope}] ${msg}${formatMeta(meta)}
952
+ `;
953
+ if (options.mirrorConsole) mirror(level, line);
954
+ this.core.sink.write(line);
1017
955
  }
1018
- if (typeof quote !== "string" || !quote.trim()) return { error: "quote required" };
1019
- if (typeof note !== "string" || !note.trim()) return { error: "note required" };
1020
- if (sectionPath.length > 200) return { error: "sectionPath too long (max 200)" };
1021
- if (quote.length > 2e3) return { error: "quote too long (max 2000)" };
1022
- if (note.length > 2e3) return { error: "note too long (max 2000)" };
1023
- return { sectionPath, quote, note };
1024
956
  }
1025
- const KINDS = ["claude", "codex", "opencode"];
1026
- const DRAFT_ID_RE = /^[a-zA-Z0-9-]{1,64}$/;
1027
- function buildChatPrompt(prompt, draftId, attachments) {
1028
- if (attachments.length === 0) return prompt;
1029
- const dir = `.yorz/tmp/drafts/${draftId}/attachments`;
1030
- const lines = attachments.map((a) => {
1031
- const rel = `${dir}/${a.storedName}`;
1032
- return a.kind === "image" ? `- ![${a.name}](${rel})` : `- [${a.name}](${rel})`;
1033
- });
1034
- return [
1035
- prompt,
1036
- "",
1037
- "---",
1038
- `本次消息附带 ${attachments.length} 个附件,已保存在临时目录 \`${dir}/\`(请按需用文件工具直接读取,无需迁移):`,
1039
- ...lines
1040
- ].join("\n");
957
+ function mirror(level, line) {
958
+ const text = line.endsWith("\n") ? line.slice(0, -1) : line;
959
+ try {
960
+ if (level === "error") console.error(text);
961
+ else if (level === "warn") console.warn(text);
962
+ else console.log(text);
963
+ } catch {
964
+ }
1041
965
  }
1042
- function createSessionsRoutes(resolveProject) {
966
+ function createLogger(patch = {}) {
967
+ const options = { ...defaultOptions(), ...patch };
968
+ return new Logger({ options, sink: createSink(options) }, "yorz");
969
+ }
970
+ let singleton = null;
971
+ function getLogger() {
972
+ if (!singleton) singleton = createLogger();
973
+ return singleton;
974
+ }
975
+ function configureLogger(patch) {
976
+ const logger = getLogger();
977
+ logger.configure(patch);
978
+ return logger;
979
+ }
980
+ function createSpecsRoutes(resolveProject) {
1043
981
  const app = new Hono();
1044
982
  const need = async (c) => {
1045
983
  const id = c.req.param("projectId") ?? "";
@@ -1048,266 +986,550 @@ function createSessionsRoutes(resolveProject) {
1048
986
  if (!project) return c.json({ error: "project not found" }, 404);
1049
987
  return project;
1050
988
  };
1051
- app.get("/projects/:projectId/sessions", async (c) => {
989
+ app.get("/projects/:projectId/specs", async (c) => {
1052
990
  const p = await need(c);
1053
991
  if (p instanceof Response) return p;
1054
- return c.json(await p.sessions.listSessions());
992
+ const items = await p.store.list();
993
+ return c.json(items);
1055
994
  });
1056
- app.post("/projects/:projectId/sessions", async (c) => {
995
+ app.post("/projects/:projectId/specs", async (c) => {
1057
996
  const p = await need(c);
1058
997
  if (p instanceof Response) return p;
1059
- let body = {};
998
+ let body;
1060
999
  try {
1061
1000
  body = await c.req.json();
1062
1001
  } catch {
1002
+ return c.json({ error: "invalid JSON body" }, 400);
1003
+ }
1004
+ const input = parseCreateBody(body);
1005
+ if ("error" in input) return c.json({ error: input.error }, 400);
1006
+ if (!input.title && input.requirement) {
1007
+ const beforeIds = new Set((await p.store.list()).map((s) => s.id));
1008
+ const prompt = buildDraftPrompt(input.type, input.requirement, input.draftId);
1009
+ const { sessionId } = await p.sessions.createSession();
1010
+ const handle = await p.sessions.send(sessionId, prompt);
1011
+ handle.onDone((finalSessionId) => {
1012
+ void bindDraftSessionToCreatedSpec(p, beforeIds, finalSessionId);
1013
+ });
1014
+ return c.json({ runId: handle.runId, sessionId, draft: true }, 202);
1063
1015
  }
1064
- const kind = KINDS.includes(body.agentKind) ? body.agentKind : void 0;
1065
- const title = typeof body.title === "string" ? body.title : void 0;
1066
1016
  try {
1067
- const created = await p.sessions.createSession(kind, title);
1068
- return c.json(created, 201);
1017
+ const { id, path } = await p.store.create(input);
1018
+ return c.json({ id, path }, 201);
1069
1019
  } catch (err) {
1070
- return c.json({ error: err.message }, 500);
1020
+ return c.json({ error: err.message }, 400);
1071
1021
  }
1072
1022
  });
1073
- app.get("/projects/:projectId/specs/:id/session", async (c) => {
1023
+ app.get("/projects/:projectId/specs/:id", async (c) => {
1024
+ const p = await need(c);
1025
+ if (p instanceof Response) return p;
1026
+ const detail = await p.store.read(c.req.param("id"));
1027
+ if (!detail) return c.json({ error: "spec not found" }, 404);
1028
+ return c.json(detail);
1029
+ });
1030
+ app.patch("/projects/:projectId/specs/:id/stage", async (c) => {
1074
1031
  const p = await need(c);
1075
1032
  if (p instanceof Response) return p;
1076
1033
  const specId = c.req.param("id");
1077
- const detail = await p.store.read(specId);
1034
+ let body;
1035
+ try {
1036
+ body = await c.req.json();
1037
+ } catch {
1038
+ return c.json({ error: "invalid JSON body" }, 400);
1039
+ }
1040
+ const stage = body && typeof body === "object" ? body.stage : void 0;
1041
+ if (stage !== "plan" && stage !== "tasks" && stage !== "execute" && stage !== "done") {
1042
+ return c.json({ error: "stage must be plan | tasks | execute | done" }, 400);
1043
+ }
1044
+ try {
1045
+ await p.store.setStage(specId, stage);
1046
+ return c.json({ ok: true });
1047
+ } catch (err) {
1048
+ return c.json({ error: err.message }, 404);
1049
+ }
1050
+ });
1051
+ app.get("/projects/:projectId/specs/:id/attachments/:name", async (c) => {
1052
+ const p = await need(c);
1053
+ if (p instanceof Response) return p;
1054
+ const id = c.req.param("id");
1055
+ const name2 = c.req.param("name");
1056
+ if (!isSafeAttachmentName(name2)) {
1057
+ return c.json({ error: "invalid attachment name" }, 400);
1058
+ }
1059
+ const detail = await p.store.read(id);
1078
1060
  if (!detail) return c.json({ error: "spec not found" }, 404);
1061
+ const file = join(p.specsDir, id, "attachments", name2);
1062
+ if (!existsSync(file)) return c.json({ error: "attachment not found" }, 404);
1063
+ const ext = extname(name2).toLowerCase();
1064
+ const mime = mimeForExt(ext);
1065
+ const kind = classifyMime(mime);
1066
+ const data = await readFile(file);
1067
+ c.header("Content-Type", mime);
1068
+ c.header(
1069
+ "Content-Disposition",
1070
+ kind === "image" || kind === "pdf" || kind === "text" ? "inline" : "attachment"
1071
+ );
1072
+ c.header("Cache-Control", "max-age=300");
1073
+ return c.body(new Uint8Array(data));
1074
+ });
1075
+ app.post("/projects/:projectId/specs/:id/inputs", async (c) => {
1076
+ const p = await need(c);
1077
+ if (p instanceof Response) return p;
1078
+ let body;
1079
1079
  try {
1080
- const found = await p.sessions.findSessionForSpec(specId);
1081
- if (!found) return c.json({ sessionId: null, kind: null, running: false });
1082
- return c.json({ ...found, running: p.sessions.isRunning(found.sessionId) });
1080
+ body = await c.req.json();
1081
+ } catch {
1082
+ return c.json({ error: "invalid JSON body" }, 400);
1083
+ }
1084
+ const parsed = parseAnnotateBody(body);
1085
+ if ("error" in parsed) return c.json({ error: parsed.error }, 400);
1086
+ try {
1087
+ await p.store.appendAnnotation(c.req.param("id"), parsed);
1088
+ return c.json({ ok: true });
1083
1089
  } catch (err) {
1084
- return c.json({ error: err.message }, 500);
1090
+ return c.json({ error: err.message }, 404);
1085
1091
  }
1086
1092
  });
1087
- app.get("/projects/:projectId/sessions/:sid/messages", async (c) => {
1093
+ app.post("/projects/:projectId/specs/:id/questions/answers", async (c) => {
1088
1094
  const p = await need(c);
1089
1095
  if (p instanceof Response) return p;
1096
+ let body;
1090
1097
  try {
1091
- return c.json(await p.sessions.getMessages(c.req.param("sid")));
1098
+ body = await c.req.json();
1099
+ } catch {
1100
+ return c.json({ error: "invalid JSON body" }, 400);
1101
+ }
1102
+ const parsed = parseQuestionAnswersBody(body);
1103
+ if ("error" in parsed) return c.json({ error: parsed.error }, 400);
1104
+ try {
1105
+ await p.store.applyQuestionAnswers(c.req.param("id"), parsed);
1106
+ return c.json({ ok: true });
1092
1107
  } catch (err) {
1093
- return c.json({ error: err.message }, 500);
1108
+ const msg = err.message;
1109
+ const status = /spec not found/.test(msg) ? 404 : 400;
1110
+ return c.json({ error: msg }, status);
1094
1111
  }
1095
1112
  });
1096
- app.post("/projects/:projectId/sessions/:sid/messages", async (c) => {
1113
+ app.post("/projects/:projectId/specs/:id/appends", async (c) => {
1097
1114
  const p = await need(c);
1098
1115
  if (p instanceof Response) return p;
1099
- let body = {};
1116
+ const specId = c.req.param("id");
1117
+ let body;
1100
1118
  try {
1101
1119
  body = await c.req.json();
1102
1120
  } catch {
1103
1121
  return c.json({ error: "invalid JSON body" }, 400);
1104
1122
  }
1105
- const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
1106
- if (!prompt) return c.json({ error: "prompt required" }, 400);
1107
- let finalPrompt = prompt;
1108
- if (body.draftId !== void 0) {
1109
- if (typeof body.draftId !== "string" || !DRAFT_ID_RE.test(body.draftId)) {
1110
- return c.json({ error: "draftId has invalid format" }, 400);
1111
- }
1112
- const draftId = body.draftId;
1113
- if (await p.attachments.draftExists(draftId)) {
1114
- const metas = await p.attachments.listAttachments(draftId);
1115
- finalPrompt = buildChatPrompt(prompt, draftId, metas);
1116
- }
1123
+ const parsed = parseAppendBody(body);
1124
+ if ("error" in parsed) return c.json({ error: parsed.error }, 400);
1125
+ const detail = await p.store.read(specId);
1126
+ if (!detail) return c.json({ error: "spec not found" }, 404);
1127
+ try {
1128
+ await p.store.appendItem(specId, {
1129
+ kind: parsed.kind,
1130
+ description: parsed.description,
1131
+ sectionPath: parsed.sectionPath,
1132
+ quote: parsed.quote
1133
+ });
1134
+ } catch (err) {
1135
+ return c.json({ error: err.message }, 400);
1117
1136
  }
1118
- const handle = await p.sessions.send(c.req.param("sid"), finalPrompt);
1119
- return c.json({ runId: handle.runId, sessionId: handle.sessionId }, 202);
1137
+ if (parsed.autoRun) {
1138
+ const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
1139
+ const debugActive = await readDebugMdStatus(join(p.specsDir, specId)) === "debugging";
1140
+ const prompt = parsed.debug ? buildDebugPrompt(p.specsDirRelative, specId, "new", await buildDebugRuntimeContext(p)) : debugActive ? buildDebugPrompt(
1141
+ p.specsDirRelative,
1142
+ specId,
1143
+ "resume",
1144
+ await buildDebugRuntimeContext(p)
1145
+ ) : `请使用 yorz-spec skill 处理 spec:${p.specsDirRelative}/${specId}/spec.md`;
1146
+ const handle = await p.sessions.send(sessionId, prompt);
1147
+ return c.json({ ok: true, runId: handle.runId, sessionId });
1148
+ }
1149
+ return c.json({ ok: true });
1120
1150
  });
1121
- app.post("/projects/:projectId/sessions/:sid/abort", async (c) => {
1151
+ app.post("/projects/:projectId/specs/:id/run", async (c) => {
1122
1152
  const p = await need(c);
1123
1153
  if (p instanceof Response) return p;
1124
- const ok = await p.sessions.abort(c.req.param("sid"));
1125
- return c.json({ aborted: ok });
1154
+ const specId = c.req.param("id");
1155
+ const detail = await p.store.read(specId);
1156
+ if (!detail) return c.json({ error: "spec not found" }, 404);
1157
+ const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
1158
+ const debugActive = await readDebugMdStatus(join(p.specsDir, specId)) === "debugging";
1159
+ const prompt = debugActive ? buildDebugPrompt(p.specsDirRelative, specId, "resume", await buildDebugRuntimeContext(p)) : `请使用 yorz-spec skill 处理 spec:${p.specsDirRelative}/${specId}/spec.md`;
1160
+ const handle = await p.sessions.send(sessionId, prompt);
1161
+ return c.json({ runId: handle.runId, sessionId });
1126
1162
  });
1127
- return app;
1128
- }
1129
- const execFileP$1 = promisify(execFile);
1130
- class GitError extends Error {
1131
- code;
1132
- stderr;
1133
- constructor(code, message, stderr = "") {
1134
- super(message);
1135
- this.name = "GitError";
1136
- this.code = code;
1137
- this.stderr = stderr;
1138
- }
1139
- }
1140
- const STATUS_FROM_CODE = {
1141
- " M": "M",
1142
- "M ": "M",
1143
- MM: "M",
1144
- " A": "A",
1145
- "A ": "A",
1146
- AM: "A",
1147
- " D": "D",
1148
- "D ": "D",
1149
- " R": "R",
1150
- "R ": "R",
1151
- "??": "??"
1152
- };
1153
- function classifyStatus(index, worktree) {
1154
- const key = `${index}${worktree}`;
1155
- if (STATUS_FROM_CODE[key]) return STATUS_FROM_CODE[key];
1156
- if (index === "?" && worktree === "?") return "??";
1157
- if (worktree === "M") return "M";
1158
- if (worktree === "D") return "D";
1159
- if (index === "A") return "A";
1160
- if (index === "M") return "M";
1161
- if (index === "D") return "D";
1162
- if (index === "R") return "R";
1163
- return index === " " ? worktree : index;
1164
- }
1165
- function assertSafeRelativePath(cwd, p) {
1166
- if (typeof p !== "string" || p.length === 0) {
1167
- throw new GitError("invalid_path", "path must be a non-empty string");
1168
- }
1169
- if (p.startsWith("/")) {
1170
- throw new GitError("invalid_path", `absolute path not allowed: ${p}`);
1171
- }
1172
- if (p.split(/[\\/]/).some((seg) => seg === "..")) {
1173
- throw new GitError("invalid_path", `parent traversal not allowed: ${p}`);
1174
- }
1175
- const absolute = resolve(cwd, p);
1176
- const cwdWithSep = cwd.endsWith(sep) ? cwd : `${cwd}${sep}`;
1177
- if (absolute !== cwd && !absolute.startsWith(cwdWithSep)) {
1178
- throw new GitError("invalid_path", `path escapes cwd: ${p}`);
1163
+ app.post("/projects/:projectId/specs/:id/explain", async (c) => {
1164
+ const p = await need(c);
1165
+ if (p instanceof Response) return p;
1166
+ const specId = c.req.param("id");
1167
+ let body;
1168
+ try {
1169
+ body = await c.req.json();
1170
+ } catch {
1171
+ return c.json({ error: "invalid JSON body" }, 400);
1172
+ }
1173
+ const text = body && typeof body === "object" ? body.text : void 0;
1174
+ if (typeof text !== "string" || !text.trim()) {
1175
+ return c.json({ error: "text required" }, 400);
1176
+ }
1177
+ if (text.length > 4e3) {
1178
+ return c.json({ error: "text too long (max 4000)" }, 400);
1179
+ }
1180
+ const detail = await p.store.read(specId);
1181
+ if (!detail) return c.json({ error: "spec not found" }, 404);
1182
+ const prompt = `以下为 spec 文档 ${p.specsDirRelative}/${specId}/spec.md 中的一段内容。
1183
+ 请用中文简洁解释其含义、背景与可能的实施影响。**不要**修改任何文件,只在终端输出解释文本。
1184
+
1185
+ 引用:
1186
+ """
1187
+ ${text}
1188
+ """
1189
+ `;
1190
+ const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
1191
+ const handle = await p.sessions.send(sessionId, prompt);
1192
+ return c.json({ runId: handle.runId, sessionId });
1193
+ });
1194
+ app.delete("/projects/:projectId/specs/:id", async (c) => {
1195
+ const p = await need(c);
1196
+ if (p instanceof Response) return p;
1197
+ const specId = c.req.param("id");
1198
+ const detail = await p.store.read(specId);
1199
+ if (!detail) return c.json({ error: "spec not found" }, 404);
1200
+ await p.store.delete(specId);
1201
+ return c.json({ ok: true });
1202
+ });
1203
+ return app;
1204
+ }
1205
+ function buildDraftPrompt(type2, requirement, draftId) {
1206
+ const lines = [
1207
+ "请按 yorz-spec skill 的「新建 spec」流程,根据下方信息创建新的 spec 文档,并立即按 plan 阶段继续推进直至阻塞。",
1208
+ "",
1209
+ `类型:${type2}(已由调用方指定,不要再询问)`,
1210
+ "需求:",
1211
+ '"""',
1212
+ requirement.trim(),
1213
+ '"""',
1214
+ "",
1215
+ "硬性要求:",
1216
+ `- 生成 kebab-case summary-name 时只使用对需求有语义代表性的英文/数字字符;如难以从中文中提炼出可读 slug,请直接使用 \`untitled-\` + 3 位日期内自增编号占位,禁止把中文挤压为零散英文片段(例如禁止出现 \`spec-agent-spec-agent\` 之类的拼接)。`,
1217
+ "- frontmatter.summary 必须是对需求的真实概述(≤200 字符),不要原样照搬整段需求。",
1218
+ "- 完成 spec 文件初始化后立即进入 plan 阶段,按 SKILL 规则补齐 `现状分析` / `技术实现方案` / `待确认项`,再视需要进入 tasks/execute。"
1219
+ ];
1220
+ if (draftId) {
1221
+ lines.push(
1222
+ "",
1223
+ `附件迁移:本次新建 spec 关联了草稿附件目录 \`.yorz/tmp/drafts/${draftId}/attachments/\`。`,
1224
+ "- 在创建 `.yorz/specs/<id>/` 目录并写入 `spec.md` 骨架**之后**,立即把该 draft 目录下的所有文件迁移到 `.yorz/specs/<id>/attachments/`,文件名保持不变。",
1225
+ "- 迁移完成后,在 `## 背景` 章节末尾追加一段附件列表,每个附件占一行(按文件扩展名判定 `kind`):",
1226
+ " - 图片(`.png` / `.jpg` / `.jpeg` / `.gif` / `.webp` / `.bmp` / `.svg` / `.avif` / `.heic`):使用 `![<文件名>](attachments/<文件名>)`",
1227
+ " - PDF(`.pdf`) / 文本(`.txt` / `.md` / `.markdown`):使用 `[<文件名>](attachments/<文件名>)`",
1228
+ "- 迁移失败(如 draft 目录已被清理、权限不足)时,**不要静默丢弃**:在 `## 待确认项` 章节追加一条记录说明问题,并退出本轮等待用户介入。"
1229
+ );
1179
1230
  }
1180
- return p;
1231
+ return lines.join("\n");
1181
1232
  }
1182
- async function runGit(cwd, args) {
1233
+ async function bindDraftSessionToCreatedSpec(project, beforeIds, sessionId) {
1183
1234
  try {
1184
- const result = await execFileP$1("git", args, {
1185
- cwd,
1186
- encoding: "utf8",
1187
- maxBuffer: 16 * 1024 * 1024
1188
- });
1189
- return { stdout: result.stdout, stderr: result.stderr };
1190
- } catch (err) {
1191
- const e = err;
1192
- if (e.code === "ENOENT") {
1193
- throw new GitError("git_missing", "git not found on PATH");
1235
+ const created = (await project.store.list()).filter((s) => !beforeIds.has(s.id));
1236
+ if (created.length !== 1) {
1237
+ getLogger().child("agent").warn("skip draft session binding", {
1238
+ sessionId,
1239
+ createdCount: created.length
1240
+ });
1241
+ return;
1194
1242
  }
1195
- throw new GitError("git_failed", `git ${args[0]} failed`, e.stderr ?? e.message);
1243
+ const [spec] = created;
1244
+ const ok = await project.sessions.bindSessionToSpec(
1245
+ sessionId,
1246
+ spec.id,
1247
+ formatSpecSessionTitle(spec.id, spec.summary)
1248
+ );
1249
+ if (!ok) {
1250
+ getLogger().child("agent").warn("draft session binding target missing", {
1251
+ sessionId,
1252
+ specId: spec.id
1253
+ });
1254
+ }
1255
+ } catch (err) {
1256
+ getLogger().child("agent").warn("draft session binding failed", {
1257
+ sessionId,
1258
+ message: err instanceof Error ? err.message : String(err)
1259
+ });
1196
1260
  }
1197
1261
  }
1198
- async function runGitChecked(cwd, args) {
1199
- return runGit(cwd, args);
1262
+ function formatSpecSessionTitle(specId, summary) {
1263
+ const s = summary.trim();
1264
+ return s ? `${specId} · ${s}` : specId;
1200
1265
  }
1201
- async function runGitRaw(cwd, args) {
1266
+ async function readDebugMdStatus(specDir) {
1267
+ const file = join(specDir, "debug.md");
1268
+ let text;
1202
1269
  try {
1203
- const result = await execFileP$1("git", args, {
1204
- cwd,
1205
- encoding: "utf8",
1206
- maxBuffer: 16 * 1024 * 1024
1207
- });
1208
- return { stdout: result.stdout, stderr: result.stderr, code: 0 };
1209
- } catch (err) {
1210
- const e = err;
1211
- if (e.code === "ENOENT") {
1212
- throw new GitError("git_missing", "git not found on PATH");
1213
- }
1214
- const code = typeof e.code === "number" ? e.code : 1;
1215
- return { stdout: e.stdout ?? "", stderr: e.stderr ?? e.message ?? "", code };
1270
+ text = await readFile(file, "utf8");
1271
+ } catch {
1272
+ return null;
1216
1273
  }
1274
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
1275
+ if (!m) return null;
1276
+ const statusLine = m[1].split(/\r?\n/).find((l) => /^status\s*:/.test(l.trim()));
1277
+ if (!statusLine) return null;
1278
+ const val = statusLine.slice(statusLine.indexOf(":") + 1).trim().replace(/^['"]|['"]$/g, "");
1279
+ return val === "debugging" ? "debugging" : val === "resolved" ? "resolved" : null;
1217
1280
  }
1218
- async function listChanges(cwd) {
1219
- const { stdout } = await runGit(cwd, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
1220
- return parsePorcelain(stdout);
1281
+ function buildDebugPrompt(specsDirRelative, specId, mode, runtimeContext = "") {
1282
+ const specPath = `${specsDirRelative}/${specId}/spec.md`;
1283
+ const debugPath = `${specsDirRelative}/${specId}/debug.md`;
1284
+ if (mode === "resume") {
1285
+ return `请使用 yorz-debug skill 继续调试:spec 目录 ${specsDirRelative}/${specId}(含 ${specPath})。该目录下已存在处于 debugging 状态的 ${debugPath},请定位其活跃记录块(frontmatter active 指向的 ## Debug NNN)继续「假设 → 取证 → 验证」循环,勿新建记录块。` + runtimeContext;
1286
+ }
1287
+ return `请使用 yorz-debug skill 进入 Debug 模式:spec 目录 ${specsDirRelative}/${specId}(含 ${specPath})。若 ${debugPath} 不存在则创建,否则在文末追加新的 ## Debug 记录块;进入后立即 git stash create 打快照写入 Debug 基线,再开始「假设 → 取证 → 验证」循环。` + runtimeContext;
1221
1288
  }
1222
- function parsePorcelain(raw) {
1223
- const out = [];
1224
- if (!raw) return out;
1225
- const records = raw.split("\0");
1226
- for (let i = 0; i < records.length; i++) {
1227
- const rec = records[i];
1228
- if (!rec) continue;
1229
- if (rec.length < 3) continue;
1230
- const index = rec[0];
1231
- const worktree = rec[1];
1232
- const path = rec.slice(3);
1233
- const change = {
1234
- path,
1235
- index,
1236
- worktree,
1237
- status: classifyStatus(index, worktree)
1238
- };
1239
- if (index === "R" || worktree === "R") {
1240
- const from = records[i + 1] ?? "";
1241
- change.renamedFrom = from;
1242
- i += 1;
1289
+ async function buildDebugRuntimeContext(project) {
1290
+ return formatDebugRuntimeContext(await project.commands.listRuns());
1291
+ }
1292
+ function formatDebugRuntimeContext(runs) {
1293
+ const running = runs.filter((r) => r.status === "running");
1294
+ if (running.length === 0) {
1295
+ return "\n\n当前项目运行服务上下文:暂无运行中的命令服务。若复现需要服务,请根据项目脚本自行启动,或请用户在 GUI 命令菜单启动后重试。";
1296
+ }
1297
+ const lines = ["\n\n当前项目运行服务上下文:"];
1298
+ for (const run of running) {
1299
+ lines.push(
1300
+ `- runId: ${promptScalar(run.runId)}`,
1301
+ ` name: ${promptScalar(run.name)}`,
1302
+ ` cli: ${promptScalar(run.cli)}`,
1303
+ ` status: ${run.status}`,
1304
+ ` pid: ${run.pid}`,
1305
+ ` startedAt: ${new Date(run.startedAt).toISOString()}`,
1306
+ ` logFile: ${promptScalar(run.logFile)}`
1307
+ );
1308
+ }
1309
+ return lines.join("\n");
1310
+ }
1311
+ function promptScalar(value) {
1312
+ return value.replace(/\r?\n/g, "\\n");
1313
+ }
1314
+ function parseCreateBody(body) {
1315
+ if (!body || typeof body !== "object") return { error: "body must be an object" };
1316
+ const obj = body;
1317
+ const rawType = obj.type ?? "feat";
1318
+ if (rawType !== "feat" && rawType !== "refct" && rawType !== "fix") {
1319
+ return { error: "type must be feat | refct | fix" };
1320
+ }
1321
+ const out = { type: rawType };
1322
+ if (typeof obj.title === "string" && obj.title.trim()) out.title = obj.title;
1323
+ if (typeof obj.summary === "string" && obj.summary.trim()) out.summary = obj.summary;
1324
+ if (typeof obj.requirement === "string" && obj.requirement.trim()) {
1325
+ out.requirement = obj.requirement;
1326
+ }
1327
+ if (obj.draftId !== void 0) {
1328
+ if (typeof obj.draftId !== "string" || !obj.draftId.trim()) {
1329
+ return { error: "draftId must be a non-empty string" };
1243
1330
  }
1244
- out.push(change);
1331
+ if (!/^[a-zA-Z0-9-]{1,64}$/.test(obj.draftId)) {
1332
+ return { error: "draftId has invalid format" };
1333
+ }
1334
+ out.draftId = obj.draftId;
1245
1335
  }
1246
1336
  return out;
1247
1337
  }
1248
- async function partitionPathsByStatus(cwd, paths) {
1249
- const all = await listChanges(cwd);
1250
- const byPath = new Map(all.map((c) => [c.path, c]));
1251
- const result = { tracked: [], untracked: [], renamed: [] };
1252
- for (const p of paths) {
1253
- const change = byPath.get(p);
1254
- if (!change) continue;
1255
- if (change.status === "??") {
1256
- result.untracked.push(p);
1257
- } else if (change.renamedFrom) {
1258
- result.renamed.push({ path: p, renamedFrom: change.renamedFrom });
1259
- } else {
1260
- result.tracked.push(p);
1338
+ function isSafeAttachmentName(name2) {
1339
+ if (!name2) return false;
1340
+ if (name2.includes("/") || name2.includes("\\")) return false;
1341
+ if (name2 === "." || name2 === "..") return false;
1342
+ if (name2.startsWith(".")) return false;
1343
+ return true;
1344
+ }
1345
+ function parseQuestionAnswersBody(body) {
1346
+ if (!body || typeof body !== "object") return { error: "body must be an object" };
1347
+ const obj = body;
1348
+ const rawAnswers = obj.answers;
1349
+ const rawFreeforms = obj.freeformAnnotations;
1350
+ if (rawAnswers !== void 0 && !Array.isArray(rawAnswers)) {
1351
+ return { error: "answers must be an array" };
1352
+ }
1353
+ if (rawFreeforms !== void 0 && !Array.isArray(rawFreeforms)) {
1354
+ return { error: "freeformAnnotations must be an array" };
1355
+ }
1356
+ const answers = [];
1357
+ for (const a of rawAnswers ?? []) {
1358
+ if (!a || typeof a !== "object") return { error: "answer must be an object" };
1359
+ const ao = a;
1360
+ if (typeof ao.questionText !== "string" || !ao.questionText.trim()) {
1361
+ return { error: "answer.questionText required" };
1362
+ }
1363
+ const item = { questionText: ao.questionText };
1364
+ if (typeof ao.questionId === "string") item.questionId = ao.questionId;
1365
+ if (typeof ao.selectedOptionLabel === "string" && ao.selectedOptionLabel.trim()) {
1366
+ item.selectedOptionLabel = ao.selectedOptionLabel;
1367
+ }
1368
+ if (typeof ao.note === "string" && ao.note.trim()) item.note = ao.note;
1369
+ if (!item.selectedOptionLabel && !item.note) {
1370
+ return { error: "answer requires selectedOptionLabel or note" };
1261
1371
  }
1372
+ answers.push(item);
1262
1373
  }
1263
- return result;
1264
- }
1265
- async function commit(cwd, opts) {
1266
- const message = opts.message?.trim() ?? "";
1267
- if (!message) throw new GitError("invalid_message", "commit message must not be empty");
1268
- if (!Array.isArray(opts.paths) || opts.paths.length === 0) {
1269
- throw new GitError("no_paths", "commit requires at least one path");
1374
+ const freeformAnnotations = [];
1375
+ for (const f of rawFreeforms ?? []) {
1376
+ if (!f || typeof f !== "object") return { error: "freeformAnnotation must be an object" };
1377
+ const fo = f;
1378
+ if (typeof fo.sectionPath !== "string" || !fo.sectionPath.trim()) {
1379
+ return { error: "freeformAnnotation.sectionPath required" };
1380
+ }
1381
+ if (typeof fo.quote !== "string" || !fo.quote.trim()) {
1382
+ return { error: "freeformAnnotation.quote required" };
1383
+ }
1384
+ if (typeof fo.note !== "string" || !fo.note.trim()) {
1385
+ return { error: "freeformAnnotation.note required" };
1386
+ }
1387
+ freeformAnnotations.push({
1388
+ sectionPath: fo.sectionPath,
1389
+ quote: fo.quote,
1390
+ note: fo.note
1391
+ });
1270
1392
  }
1271
- for (const p of opts.paths) assertSafeRelativePath(cwd, p);
1272
- const { renamed } = await partitionPathsByStatus(cwd, opts.paths);
1273
- const extraPaths = renamed.map((r) => r.renamedFrom);
1274
- const allPaths = [...opts.paths, ...extraPaths];
1275
- await runGit(cwd, ["add", "--", ...allPaths]);
1276
- await runGit(cwd, ["commit", "-m", message, "--", ...allPaths]);
1277
- const { stdout } = await runGit(cwd, ["rev-parse", "HEAD"]);
1278
- return { commit: stdout.trim() };
1393
+ if (answers.length === 0 && freeformAnnotations.length === 0) {
1394
+ return { error: "answers or freeformAnnotations required" };
1395
+ }
1396
+ return { answers, freeformAnnotations };
1279
1397
  }
1280
- async function discard(cwd, opts) {
1281
- if (!Array.isArray(opts.paths) || opts.paths.length === 0) {
1282
- throw new GitError("no_paths", "discard requires at least one path");
1398
+ function parseAppendBody(body) {
1399
+ if (!body || typeof body !== "object") return { error: "body must be an object" };
1400
+ const obj = body;
1401
+ const kind = obj.kind;
1402
+ if (kind !== "feat" && kind !== "refct" && kind !== "fix") {
1403
+ return { error: "kind must be feat | refct | fix" };
1404
+ }
1405
+ if (typeof obj.description !== "string" || !obj.description.trim()) {
1406
+ return { error: "description required" };
1407
+ }
1408
+ const out = { kind, description: obj.description, autoRun: true, debug: false };
1409
+ if (obj.sectionPath !== void 0) {
1410
+ if (typeof obj.sectionPath !== "string") return { error: "sectionPath must be a string" };
1411
+ if (obj.sectionPath.length > 200) return { error: "sectionPath too long (max 200)" };
1412
+ if (obj.sectionPath.trim()) out.sectionPath = obj.sectionPath;
1413
+ }
1414
+ if (obj.quote !== void 0) {
1415
+ if (typeof obj.quote !== "string") return { error: "quote must be a string" };
1416
+ if (obj.quote.length > 500) return { error: "quote too long (max 500)" };
1417
+ if (obj.quote.trim()) out.quote = obj.quote;
1418
+ }
1419
+ if (obj.autoRun !== void 0) {
1420
+ if (typeof obj.autoRun !== "boolean") return { error: "autoRun must be a boolean" };
1421
+ out.autoRun = obj.autoRun;
1283
1422
  }
1284
- for (const p of opts.paths) assertSafeRelativePath(cwd, p);
1285
- const { tracked, untracked, renamed } = await partitionPathsByStatus(cwd, opts.paths);
1286
- if (tracked.length) {
1287
- await runGit(cwd, ["restore", "--staged", "--worktree", "--", ...tracked]);
1423
+ if (obj.debug !== void 0) {
1424
+ if (typeof obj.debug !== "boolean") return { error: "debug must be a boolean" };
1425
+ out.debug = obj.debug && kind === "fix";
1288
1426
  }
1289
- if (untracked.length) {
1290
- await runGit(cwd, ["clean", "-fd", "--", ...untracked]);
1427
+ return out;
1428
+ }
1429
+ function parseAnnotateBody(body) {
1430
+ if (!body || typeof body !== "object") return { error: "body must be an object" };
1431
+ const obj = body;
1432
+ if (obj.kind !== "annotate") {
1433
+ return { error: 'kind must be "annotate"' };
1291
1434
  }
1292
- for (const r of renamed) {
1293
- await runGitRaw(cwd, ["reset", "-q", "HEAD", "--", r.renamedFrom, r.path]);
1294
- await runGit(cwd, ["restore", "--worktree", "--", r.renamedFrom]);
1295
- await runGit(cwd, ["clean", "-fd", "--", r.path]);
1435
+ const sectionPath = obj.sectionPath;
1436
+ const quote = obj.quote;
1437
+ const note = obj.note;
1438
+ if (typeof sectionPath !== "string" || !sectionPath.trim()) {
1439
+ return { error: "sectionPath required" };
1296
1440
  }
1441
+ if (typeof quote !== "string" || !quote.trim()) return { error: "quote required" };
1442
+ if (typeof note !== "string" || !note.trim()) return { error: "note required" };
1443
+ if (sectionPath.length > 200) return { error: "sectionPath too long (max 200)" };
1444
+ if (quote.length > 2e3) return { error: "quote too long (max 2000)" };
1445
+ if (note.length > 2e3) return { error: "note too long (max 2000)" };
1446
+ return { sectionPath, quote, note };
1297
1447
  }
1298
- async function stash(cwd, opts) {
1299
- if (!Array.isArray(opts.paths) || opts.paths.length === 0) {
1300
- throw new GitError("no_paths", "stash requires at least one path");
1448
+ const CHAT_DEBUG_DIR_REL = ".yorz/tmp/debug";
1449
+ const CHAT_DEBUG_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
1450
+ const CHAT_DEBUG_CLEANUP_INTERVAL_MS = 60 * 60 * 1e3;
1451
+ function isYorzDebugCommand(prompt) {
1452
+ return /^\/yorz-debug(?:\s|$)/.test(prompt.trim());
1453
+ }
1454
+ function buildChatDebugPrompt(prompt, now = /* @__PURE__ */ new Date()) {
1455
+ const body = prompt.trim().replace(/^\/yorz-debug(?:\s+|$)/, "").trim();
1456
+ const debugPath = `${CHAT_DEBUG_DIR_REL}/debug-${formatDebugTimestamp(now)}.md`;
1457
+ const bug = body || "请先根据对话上下文确认待调试问题;若上下文不足,请向用户补齐复现信息。";
1458
+ return [
1459
+ `请使用 yorz-debug skill 进入 Debug 模式。`,
1460
+ `本次是普通 chat 独立触发,没有 spec_dir。Debug 活文档必须写入临时文件 \`${debugPath}\`。`,
1461
+ `写入前请确保目录 \`${CHAT_DEBUG_DIR_REL}/\` 存在;该目录属于临时目录,会由 YorZ 定时清理。`,
1462
+ `如果该文件不存在则创建;本文件只承载本次 chat debug 记录,不需要追加复用其他文件。`,
1463
+ "",
1464
+ "待调试问题:",
1465
+ bug
1466
+ ].join("\n");
1467
+ }
1468
+ async function cleanupExpiredChatDebugFiles(projectPath, opts = {}) {
1469
+ const root = join(projectPath, CHAT_DEBUG_DIR_REL);
1470
+ if (!existsSync(root)) return { removed: [] };
1471
+ const removed = [];
1472
+ const cutoff = (opts.now ?? Date.now()) - (opts.ttlMs ?? CHAT_DEBUG_RETENTION_MS);
1473
+ let entries;
1474
+ try {
1475
+ entries = await readdir(root, { withFileTypes: true });
1476
+ } catch {
1477
+ return { removed };
1301
1478
  }
1302
- for (const p of opts.paths) assertSafeRelativePath(cwd, p);
1303
- const { renamed } = await partitionPathsByStatus(cwd, opts.paths);
1304
- const extraPaths = renamed.map((r) => r.renamedFrom);
1305
- const allPaths = [...opts.paths, ...extraPaths];
1306
- const message = opts.message?.trim() || "yorz:stash";
1307
- await runGit(cwd, ["stash", "push", "--include-untracked", "-m", message, "--", ...allPaths]);
1479
+ for (const entry of entries) {
1480
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
1481
+ const file = join(root, entry.name);
1482
+ try {
1483
+ const s = await stat(file);
1484
+ if (s.mtimeMs <= cutoff) {
1485
+ await rm(file, { force: true });
1486
+ removed.push(entry.name);
1487
+ }
1488
+ } catch {
1489
+ }
1490
+ }
1491
+ return { removed };
1308
1492
  }
1309
- const VALID_ACTIONS = /* @__PURE__ */ new Set(["commit", "discard", "stash"]);
1310
- function createSpecReviewRoutes(resolveProject) {
1493
+ function scheduleChatDebugCleanup(projectPath) {
1494
+ void mkdir(join(projectPath, CHAT_DEBUG_DIR_REL), { recursive: true }).then(() => cleanupExpiredChatDebugFiles(projectPath)).catch(() => {
1495
+ });
1496
+ const timer = setInterval(() => {
1497
+ void cleanupExpiredChatDebugFiles(projectPath).catch(() => {
1498
+ });
1499
+ }, CHAT_DEBUG_CLEANUP_INTERVAL_MS);
1500
+ timer.unref?.();
1501
+ return () => clearInterval(timer);
1502
+ }
1503
+ function formatDebugTimestamp(date) {
1504
+ const pad = (n) => String(n).padStart(2, "0");
1505
+ return [
1506
+ date.getFullYear(),
1507
+ pad(date.getMonth() + 1),
1508
+ pad(date.getDate()),
1509
+ "-",
1510
+ pad(date.getHours()),
1511
+ pad(date.getMinutes()),
1512
+ pad(date.getSeconds())
1513
+ ].join("");
1514
+ }
1515
+ const KINDS = ["claude", "codex", "opencode"];
1516
+ const DRAFT_ID_RE = /^[a-zA-Z0-9-]{1,64}$/;
1517
+ function buildChatPrompt(prompt, draftId, attachments) {
1518
+ if (attachments.length === 0) return prompt;
1519
+ const dir = `.yorz/tmp/drafts/${draftId}/attachments`;
1520
+ const lines = attachments.map((a) => {
1521
+ const rel = `${dir}/${a.storedName}`;
1522
+ return a.kind === "image" ? `- ![${a.name}](${rel})` : `- [${a.name}](${rel})`;
1523
+ });
1524
+ return [
1525
+ prompt,
1526
+ "",
1527
+ "---",
1528
+ `本次消息附带 ${attachments.length} 个附件,已保存在临时目录 \`${dir}/\`(请按需用文件工具直接读取,无需迁移):`,
1529
+ ...lines
1530
+ ].join("\n");
1531
+ }
1532
+ function createSessionsRoutes(resolveProject) {
1311
1533
  const app = new Hono();
1312
1534
  const need = async (c) => {
1313
1535
  const id = c.req.param("projectId") ?? "";
@@ -1316,541 +1538,453 @@ function createSpecReviewRoutes(resolveProject) {
1316
1538
  if (!project) return c.json({ error: "project not found" }, 404);
1317
1539
  return project;
1318
1540
  };
1319
- app.post("/projects/:projectId/specs/:id/review", async (c) => {
1541
+ app.get("/projects/:projectId/sessions", async (c) => {
1320
1542
  const p = await need(c);
1321
1543
  if (p instanceof Response) return p;
1322
- const specId = c.req.param("id");
1323
- const detail = await p.store.read(specId);
1324
- if (!detail) return c.json({ error: "spec not found" }, 404);
1325
- const specRel = `${p.specsDirRelative}/${specId}/spec.md`;
1326
- const reviewRel = `${p.specsDirRelative}/${specId}/review.md`;
1327
- const prompt = `请使用 yorz-spec skill 的 "Review / Git Ops 阶段" 流程处理本次 review:
1328
- - spec 文档:${specRel}
1329
- - 输出文件:${reviewRel}(追加新二级标题条目,禁止覆盖历史)
1330
- - 必含 4 节:变更总结 / 影响范围 / 风险提醒 / 变更文件清单
1331
- - 触发时间使用本机当前时间,格式 \`YYYY-MM-DD HH:mm:ss\`
1332
- `;
1333
- const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
1334
- const handle = await p.sessions.send(sessionId, prompt);
1335
- return c.json({ runId: handle.runId, sessionId });
1544
+ return c.json(await p.sessions.listSessions());
1336
1545
  });
1337
- app.post("/projects/:projectId/specs/:id/git", async (c) => {
1546
+ app.post("/projects/:projectId/sessions", async (c) => {
1338
1547
  const p = await need(c);
1339
1548
  if (p instanceof Response) return p;
1340
- const specId = c.req.param("id");
1341
- const detail = await p.store.read(specId);
1342
- if (!detail) return c.json({ error: "spec not found" }, 404);
1343
- let body;
1549
+ let body = {};
1344
1550
  try {
1345
1551
  body = await c.req.json();
1346
1552
  } catch {
1347
- return c.json({ error: "invalid JSON body" }, 400);
1348
- }
1349
- const action = body && typeof body === "object" ? body.action : null;
1350
- if (!action || typeof action !== "string" || !VALID_ACTIONS.has(action)) {
1351
- return c.json({ error: "action must be one of commit | discard | stash" }, 400);
1352
- }
1353
- const specRel = `${p.specsDirRelative}/${specId}/spec.md`;
1354
- const reviewRel = `${p.specsDirRelative}/${specId}/review.md`;
1355
- const prompt = buildGitOpsPrompt(action, specId, specRel, reviewRel);
1356
- const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
1357
- const handle = await p.sessions.send(sessionId, prompt);
1358
- return c.json({ runId: handle.runId, sessionId });
1359
- });
1360
- app.get("/projects/:projectId/specs/:id/review", async (c) => {
1361
- const p = await need(c);
1362
- if (p instanceof Response) return p;
1363
- const specId = c.req.param("id");
1364
- const detail = await p.store.read(specId);
1365
- if (!detail) return c.json({ error: "spec not found" }, 404);
1366
- const file = join(p.specsDir, specId, "review.md");
1367
- if (!existsSync(file)) return c.json({ text: "" });
1368
- try {
1369
- const text = await readFile(file, "utf8");
1370
- return c.json({ text });
1371
- } catch {
1372
- return c.json({ text: "" });
1373
1553
  }
1374
- });
1375
- app.get("/projects/:projectId/specs/:id/debug", async (c) => {
1376
- const p = await need(c);
1377
- if (p instanceof Response) return p;
1378
- const specId = c.req.param("id");
1379
- const detail = await p.store.read(specId);
1380
- if (!detail) return c.json({ error: "spec not found" }, 404);
1381
- const file = join(p.specsDir, specId, "debug.md");
1382
- if (!existsSync(file)) return c.json({ exists: false, text: "" });
1554
+ const kind = KINDS.includes(body.agentKind) ? body.agentKind : void 0;
1555
+ const title = typeof body.title === "string" ? body.title : void 0;
1383
1556
  try {
1384
- const text = await readFile(file, "utf8");
1385
- return c.json({ exists: true, text });
1386
- } catch {
1387
- return c.json({ exists: false, text: "" });
1557
+ const created = await p.sessions.createSession(kind, title);
1558
+ return c.json(created, 201);
1559
+ } catch (err) {
1560
+ return c.json({ error: err.message }, 500);
1388
1561
  }
1389
1562
  });
1390
- app.get("/projects/:projectId/specs/:id/changes", async (c) => {
1563
+ app.get("/projects/:projectId/specs/:id/session", async (c) => {
1391
1564
  const p = await need(c);
1392
1565
  if (p instanceof Response) return p;
1393
1566
  const specId = c.req.param("id");
1394
1567
  const detail = await p.store.read(specId);
1395
1568
  if (!detail) return c.json({ error: "spec not found" }, 404);
1396
1569
  try {
1397
- const changes = await listChanges(p.path);
1398
- return c.json({ changes });
1570
+ const found = await p.sessions.findSessionForSpec(specId);
1571
+ if (!found) return c.json({ sessionId: null, kind: null, running: false });
1572
+ return c.json({ ...found, running: p.sessions.isRunning(found.sessionId) });
1399
1573
  } catch (err) {
1400
- if (err instanceof GitError) return c.json({ error: err.message }, 400);
1401
- throw err;
1574
+ return c.json({ error: err.message }, 500);
1402
1575
  }
1403
1576
  });
1404
- app.post("/projects/:projectId/specs/:id/commit", async (c) => {
1577
+ app.get("/projects/:projectId/sessions/:sid/messages", async (c) => {
1405
1578
  const p = await need(c);
1406
1579
  if (p instanceof Response) return p;
1407
- const specId = c.req.param("id");
1408
- const detail = await p.store.read(specId);
1409
- if (!detail) return c.json({ error: "spec not found" }, 404);
1410
- let body;
1411
- try {
1412
- body = await c.req.json();
1413
- } catch {
1414
- return c.json({ error: "invalid JSON body" }, 400);
1415
- }
1416
- const obj = body && typeof body === "object" ? body : {};
1417
- const message = typeof obj.message === "string" ? obj.message.trim() : "";
1418
- const paths = Array.isArray(obj.paths) ? obj.paths.filter((v) => typeof v === "string") : [];
1419
- if (!message) return c.json({ error: "message must not be empty" }, 400);
1420
- if (paths.length === 0) return c.json({ error: "paths must not be empty" }, 400);
1421
1580
  try {
1422
- const result = await commit(p.path, { message, paths });
1423
- return c.json(result);
1581
+ return c.json(await p.sessions.getMessages(c.req.param("sid")));
1424
1582
  } catch (err) {
1425
- if (err instanceof GitError) return c.json({ error: err.message }, 400);
1426
- throw err;
1583
+ return c.json({ error: err.message }, 500);
1427
1584
  }
1428
1585
  });
1429
- app.post("/projects/:projectId/specs/:id/discard", async (c) => {
1586
+ app.post("/projects/:projectId/sessions/:sid/messages", async (c) => {
1430
1587
  const p = await need(c);
1431
1588
  if (p instanceof Response) return p;
1432
- const specId = c.req.param("id");
1433
- const detail = await p.store.read(specId);
1434
- if (!detail) return c.json({ error: "spec not found" }, 404);
1435
- let body;
1589
+ let body = {};
1436
1590
  try {
1437
1591
  body = await c.req.json();
1438
1592
  } catch {
1439
1593
  return c.json({ error: "invalid JSON body" }, 400);
1440
1594
  }
1441
- const obj = body && typeof body === "object" ? body : {};
1442
- const paths = Array.isArray(obj.paths) ? obj.paths.filter((v) => typeof v === "string") : [];
1443
- if (paths.length === 0) return c.json({ error: "paths must not be empty" }, 400);
1444
- try {
1445
- await discard(p.path, { paths });
1446
- return c.json({ ok: true });
1447
- } catch (err) {
1448
- if (err instanceof GitError) return c.json({ error: err.message }, 400);
1449
- throw err;
1595
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
1596
+ if (!prompt) return c.json({ error: "prompt required" }, 400);
1597
+ let finalPrompt = isYorzDebugCommand(prompt) ? buildChatDebugPrompt(prompt) : prompt;
1598
+ if (body.draftId !== void 0) {
1599
+ if (typeof body.draftId !== "string" || !DRAFT_ID_RE.test(body.draftId)) {
1600
+ return c.json({ error: "draftId has invalid format" }, 400);
1601
+ }
1602
+ const draftId = body.draftId;
1603
+ if (await p.attachments.draftExists(draftId)) {
1604
+ const metas = await p.attachments.listAttachments(draftId);
1605
+ finalPrompt = buildChatPrompt(finalPrompt, draftId, metas);
1606
+ }
1450
1607
  }
1608
+ if (isYorzDebugCommand(prompt)) void cleanupExpiredChatDebugFiles(p.path).catch(() => {
1609
+ });
1610
+ const handle = await p.sessions.send(c.req.param("sid"), finalPrompt);
1611
+ return c.json({ runId: handle.runId, sessionId: handle.sessionId }, 202);
1451
1612
  });
1452
- app.post("/projects/:projectId/specs/:id/stash", async (c) => {
1613
+ app.post("/projects/:projectId/sessions/:sid/abort", async (c) => {
1453
1614
  const p = await need(c);
1454
1615
  if (p instanceof Response) return p;
1455
- const specId = c.req.param("id");
1456
- const detail = await p.store.read(specId);
1457
- if (!detail) return c.json({ error: "spec not found" }, 404);
1458
- let body;
1459
- try {
1460
- body = await c.req.json();
1461
- } catch {
1462
- return c.json({ error: "invalid JSON body" }, 400);
1463
- }
1464
- const obj = body && typeof body === "object" ? body : {};
1465
- const message = typeof obj.message === "string" ? obj.message.trim() : "";
1466
- const paths = Array.isArray(obj.paths) ? obj.paths.filter((v) => typeof v === "string") : [];
1467
- if (paths.length === 0) return c.json({ error: "paths must not be empty" }, 400);
1468
- try {
1469
- await stash(p.path, { message: message || `yorz:${specId}`, paths });
1470
- return c.json({ ok: true });
1471
- } catch (err) {
1472
- if (err instanceof GitError) return c.json({ error: err.message }, 400);
1473
- throw err;
1474
- }
1616
+ const ok = await p.sessions.abort(c.req.param("sid"));
1617
+ return c.json({ aborted: ok });
1475
1618
  });
1476
1619
  return app;
1477
1620
  }
1478
- function buildGitOpsPrompt(action, specId, specRel, reviewRel) {
1479
- const base = `请使用 yorz-spec skill 的 "Review / Git Ops 阶段" 流程执行 git 操作:
1480
- - spec 文档:${specRel}
1481
- - 最近一次 review:${reviewRel}(如不存在,请先依据 git status/diff 自行判断本次 spec 关联的变更)
1482
- - spec-id:${specId}
1483
- `;
1484
- switch (action) {
1485
- case "commit":
1486
- return base + `- 动作:git-commit。请基于 review 报告与 git status,由你自主判断本次 spec 相关的变更文件,执行 \`git add\` + \`git commit\`;commit message 由你生成,不带 scope;禁止 \`git push\` 与 \`git reset --hard\`。
1487
- `;
1488
- case "discard":
1489
- return base + `- 动作:git-discard。请使用 \`git restore --staged --worktree -- <paths>\` 与 \`git clean -fd -- <paths>\` 丢弃 spec 相关的未提交变更;对 untracked 新文件应先在终端输出列表再处理;不要预先 stash 备份;禁止 \`git reset --hard\` 与 \`git push\`。
1490
- `;
1491
- case "stash":
1492
- return base + `- 动作:git-stash。请使用 \`git stash push -m "yorz:${specId}" -- <paths>\` 暂存 spec 相关变更文件;禁止 \`git push\`。
1493
- `;
1621
+ const execFileP$1 = promisify(execFile);
1622
+ class GitError extends Error {
1623
+ code;
1624
+ stderr;
1625
+ constructor(code, message, stderr = "") {
1626
+ super(message);
1627
+ this.name = "GitError";
1628
+ this.code = code;
1629
+ this.stderr = stderr;
1494
1630
  }
1495
1631
  }
1496
- const CURRENT_VERSION$1 = 1;
1497
- const DEFAULT_GLOBAL_AGENT = { defaultKind: "claude" };
1498
- const DEFAULT_NOTIFICATIONS = {
1499
- sessionEnd: { banner: false, sound: false }
1632
+ const STATUS_FROM_CODE = {
1633
+ " M": "M",
1634
+ "M ": "M",
1635
+ MM: "M",
1636
+ " A": "A",
1637
+ "A ": "A",
1638
+ AM: "A",
1639
+ " D": "D",
1640
+ "D ": "D",
1641
+ " R": "R",
1642
+ "R ": "R",
1643
+ "??": "??"
1500
1644
  };
1501
- function resolveGlobalConfigDir(env = process.env) {
1502
- if (env.YORZ_HOME && env.YORZ_HOME.trim()) return env.YORZ_HOME.trim();
1503
- const xdg = env.XDG_CONFIG_HOME?.trim();
1504
- const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".config");
1505
- return join(base, "yorz");
1506
- }
1507
- function resolveGlobalConfigPath(env = process.env) {
1508
- return join(resolveGlobalConfigDir(env), "projects.json");
1645
+ function classifyStatus(index, worktree) {
1646
+ const key = `${index}${worktree}`;
1647
+ if (STATUS_FROM_CODE[key]) return STATUS_FROM_CODE[key];
1648
+ if (index === "?" && worktree === "?") return "??";
1649
+ if (worktree === "M") return "M";
1650
+ if (worktree === "D") return "D";
1651
+ if (index === "A") return "A";
1652
+ if (index === "M") return "M";
1653
+ if (index === "D") return "D";
1654
+ if (index === "R") return "R";
1655
+ return index === " " ? worktree : index;
1509
1656
  }
1510
- async function loadGlobalConfig(filePath) {
1511
- const fp = filePath ?? resolveGlobalConfigPath();
1512
- if (!existsSync(fp)) return defaultGlobalConfig();
1513
- let raw;
1514
- try {
1515
- raw = await readFile(fp, "utf8");
1516
- } catch {
1517
- return defaultGlobalConfig();
1657
+ function assertSafeRelativePath(cwd, p) {
1658
+ if (typeof p !== "string" || p.length === 0) {
1659
+ throw new GitError("invalid_path", "path must be a non-empty string");
1518
1660
  }
1519
- if (!raw.trim()) return defaultGlobalConfig();
1520
- let parsed;
1521
- try {
1522
- parsed = JSON.parse(raw);
1523
- } catch {
1524
- return defaultGlobalConfig();
1661
+ if (p.startsWith("/")) {
1662
+ throw new GitError("invalid_path", `absolute path not allowed: ${p}`);
1525
1663
  }
1526
- return normalizeConfig$1(parsed);
1527
- }
1528
- async function saveGlobalConfig(config, filePath) {
1529
- const fp = filePath ?? resolveGlobalConfigPath();
1530
- await mkdir(dirname(fp), { recursive: true });
1531
- const normalized = normalizeConfig$1(config);
1532
- const body = `${JSON.stringify(normalized, null, 2)}
1533
- `;
1534
- const tmp = `${fp}.tmp-${process.pid}-${Date.now().toString(36)}`;
1535
- await writeFile(tmp, body, "utf8");
1536
- await rename(tmp, fp);
1537
- }
1538
- function normalizeConfig$1(value) {
1539
- if (!value || typeof value !== "object") return defaultGlobalConfig();
1540
- const obj = value;
1541
- const projectsRaw = Array.isArray(obj.projects) ? obj.projects : [];
1542
- const projects = [];
1543
- for (const item of projectsRaw) {
1544
- if (!item || typeof item !== "object") continue;
1545
- const it = item;
1546
- const id = typeof it.id === "string" ? it.id : "";
1547
- const path = typeof it.path === "string" ? it.path : "";
1548
- if (!id || !path) continue;
1549
- const addedAt = typeof it.addedAt === "string" ? it.addedAt : "";
1550
- const lastActivityAt = typeof it.lastActivityAt === "string" ? it.lastActivityAt : null;
1551
- const worktree = normalizeWorktree(it.worktree);
1552
- const entry = { id, path, addedAt, lastActivityAt };
1553
- if (worktree) entry.worktree = worktree;
1554
- projects.push(entry);
1664
+ if (p.split(/[\\/]/).some((seg) => seg === "..")) {
1665
+ throw new GitError("invalid_path", `parent traversal not allowed: ${p}`);
1555
1666
  }
1556
- return {
1557
- version: CURRENT_VERSION$1,
1558
- projects,
1559
- agent: normalizeAgent$1(obj.agent),
1560
- notifications: normalizeNotifications(obj.notifications)
1561
- };
1667
+ const absolute = resolve(cwd, p);
1668
+ const cwdWithSep = cwd.endsWith(sep) ? cwd : `${cwd}${sep}`;
1669
+ if (absolute !== cwd && !absolute.startsWith(cwdWithSep)) {
1670
+ throw new GitError("invalid_path", `path escapes cwd: ${p}`);
1671
+ }
1672
+ return p;
1562
1673
  }
1563
- function defaultGlobalConfig() {
1564
- return {
1565
- version: CURRENT_VERSION$1,
1566
- projects: [],
1567
- agent: { defaultKind: DEFAULT_GLOBAL_AGENT.defaultKind },
1568
- notifications: {
1569
- sessionEnd: {
1570
- banner: DEFAULT_NOTIFICATIONS.sessionEnd.banner,
1571
- sound: DEFAULT_NOTIFICATIONS.sessionEnd.sound
1572
- }
1674
+ async function runGit(cwd, args) {
1675
+ try {
1676
+ const result = await execFileP$1("git", args, {
1677
+ cwd,
1678
+ encoding: "utf8",
1679
+ maxBuffer: 16 * 1024 * 1024
1680
+ });
1681
+ return { stdout: result.stdout, stderr: result.stderr };
1682
+ } catch (err) {
1683
+ const e = err;
1684
+ if (e.code === "ENOENT") {
1685
+ throw new GitError("git_missing", "git not found on PATH");
1573
1686
  }
1574
- };
1687
+ throw new GitError("git_failed", `git ${args[0]} failed`, e.stderr ?? e.message);
1688
+ }
1575
1689
  }
1576
- function normalizeAgent$1(value) {
1577
- if (!value || typeof value !== "object") return { defaultKind: DEFAULT_GLOBAL_AGENT.defaultKind };
1578
- const obj = value;
1579
- const defaultKind = obj.defaultKind;
1580
- if (defaultKind === "opencode" || defaultKind === "codex") return { defaultKind };
1581
- return { defaultKind: "claude" };
1690
+ async function runGitChecked(cwd, args) {
1691
+ return runGit(cwd, args);
1582
1692
  }
1583
- function normalizeNotifications(value) {
1584
- if (!value || typeof value !== "object") return defaultGlobalConfig().notifications;
1585
- const obj = value;
1586
- const sessionEndRaw = obj.sessionEnd;
1587
- if (!sessionEndRaw || typeof sessionEndRaw !== "object")
1588
- return defaultGlobalConfig().notifications;
1589
- const sessionEndObj = sessionEndRaw;
1590
- return {
1591
- sessionEnd: {
1592
- banner: typeof sessionEndObj.banner === "boolean" ? sessionEndObj.banner : DEFAULT_NOTIFICATIONS.sessionEnd.banner,
1593
- sound: typeof sessionEndObj.sound === "boolean" ? sessionEndObj.sound : DEFAULT_NOTIFICATIONS.sessionEnd.sound
1693
+ async function runGitRaw(cwd, args) {
1694
+ try {
1695
+ const result = await execFileP$1("git", args, {
1696
+ cwd,
1697
+ encoding: "utf8",
1698
+ maxBuffer: 16 * 1024 * 1024
1699
+ });
1700
+ return { stdout: result.stdout, stderr: result.stderr, code: 0 };
1701
+ } catch (err) {
1702
+ const e = err;
1703
+ if (e.code === "ENOENT") {
1704
+ throw new GitError("git_missing", "git not found on PATH");
1594
1705
  }
1595
- };
1596
- }
1597
- function normalizeWorktree(value) {
1598
- if (!value || typeof value !== "object") return void 0;
1599
- const it = value;
1600
- const mainProjectId = typeof it.mainProjectId === "string" ? it.mainProjectId : "";
1601
- const mainPath = typeof it.mainPath === "string" ? it.mainPath : "";
1602
- const branch = typeof it.branch === "string" ? it.branch : "";
1603
- const specId = typeof it.specId === "string" ? it.specId : "";
1604
- const createdAt = typeof it.createdAt === "string" ? it.createdAt : "";
1605
- if (!mainProjectId || !mainPath || !branch) return void 0;
1606
- const cleanSlug = typeof it.cleanSlug === "string" ? it.cleanSlug : void 0;
1607
- const meta = { mainProjectId, mainPath, branch, specId, createdAt };
1608
- if (cleanSlug) meta.cleanSlug = cleanSlug;
1609
- return meta;
1610
- }
1611
- function generateProjectId(absPath) {
1612
- const base = basename$1(absPath);
1613
- const slug = slugify(base) || "proj";
1614
- const hash = createHash("sha256").update(absPath).digest("hex").slice(0, 6);
1615
- return `${slug}-${hash}`;
1616
- }
1617
- function slugify(text) {
1618
- return text.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
1619
- }
1620
- async function addProject(absPath, filePath, now = () => /* @__PURE__ */ new Date()) {
1621
- const config = await loadGlobalConfig(filePath);
1622
- const existing = config.projects.find((p) => p.path === absPath);
1623
- if (existing) return { entry: existing, created: false };
1624
- const entry = {
1625
- id: generateProjectId(absPath),
1626
- path: absPath,
1627
- addedAt: now().toISOString(),
1628
- lastActivityAt: null
1629
- };
1630
- config.projects.push(entry);
1631
- await saveGlobalConfig(config, filePath);
1632
- return { entry, created: true };
1633
- }
1634
- async function setProjectWorktree(id, worktree, filePath) {
1635
- const config = await loadGlobalConfig(filePath);
1636
- const target = config.projects.find((p) => p.id === id);
1637
- if (!target) return false;
1638
- if (worktree) target.worktree = worktree;
1639
- else delete target.worktree;
1640
- await saveGlobalConfig(config, filePath);
1641
- return true;
1706
+ const code = typeof e.code === "number" ? e.code : 1;
1707
+ return { stdout: e.stdout ?? "", stderr: e.stderr ?? e.message ?? "", code };
1708
+ }
1642
1709
  }
1643
- async function removeProject(id, filePath) {
1644
- const config = await loadGlobalConfig(filePath);
1645
- const before = config.projects.length;
1646
- config.projects = config.projects.filter((p) => p.id !== id);
1647
- if (config.projects.length === before) return false;
1648
- await saveGlobalConfig(config, filePath);
1649
- return true;
1710
+ async function listChanges(cwd) {
1711
+ const { stdout } = await runGit(cwd, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
1712
+ return parsePorcelain(stdout);
1650
1713
  }
1651
- async function prepareProjectDir(input, cwd) {
1652
- if (typeof input !== "string" || !input.trim()) {
1653
- throw new Error("path required");
1654
- }
1655
- const trimmed = input.trim();
1656
- const abs = isAbsolute(trimmed) ? resolve(trimmed) : resolve(cwd ?? process.cwd(), trimmed);
1657
- if (!existsSync(abs)) {
1658
- throw new Error(`path does not exist: ${abs}`);
1659
- }
1660
- const stats = await stat(abs);
1661
- if (!stats.isDirectory()) {
1662
- throw new Error(`path is not a directory: ${abs}`);
1714
+ function parsePorcelain(raw) {
1715
+ const out = [];
1716
+ if (!raw) return out;
1717
+ const records = raw.split("\0");
1718
+ for (let i = 0; i < records.length; i++) {
1719
+ const rec = records[i];
1720
+ if (!rec) continue;
1721
+ if (rec.length < 3) continue;
1722
+ const index = rec[0];
1723
+ const worktree = rec[1];
1724
+ const path = rec.slice(3);
1725
+ const change = {
1726
+ path,
1727
+ index,
1728
+ worktree,
1729
+ status: classifyStatus(index, worktree)
1730
+ };
1731
+ if (index === "R" || worktree === "R") {
1732
+ const from = records[i + 1] ?? "";
1733
+ change.renamedFrom = from;
1734
+ i += 1;
1735
+ }
1736
+ out.push(change);
1663
1737
  }
1664
- await mkdir(join(abs, ".yorz", "specs"), { recursive: true });
1665
- return abs;
1666
- }
1667
- const LEVEL_WEIGHT = {
1668
- debug: 10,
1669
- info: 20,
1670
- warn: 30,
1671
- error: 40
1672
- };
1673
- const LOG_LEVELS = Object.keys(LEVEL_WEIGHT);
1674
- const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
1675
- const DEFAULT_MAX_ARCHIVES = 1;
1676
- const DEFAULT_LOG_FILE = "serve.log";
1677
- const STDIO_LOG_FILE = "serve-stdio.log";
1678
- function resolveLogDir(env = process.env) {
1679
- return join(resolveGlobalConfigDir(env), "logs");
1738
+ return out;
1680
1739
  }
1681
- function resolveLogLevel(env = process.env) {
1682
- const raw = env.YORZ_LOG_LEVEL?.trim().toLowerCase();
1683
- if (raw && LOG_LEVELS.includes(raw)) return raw;
1684
- return "info";
1740
+ async function partitionPathsByStatus(cwd, paths) {
1741
+ const all = await listChanges(cwd);
1742
+ const byPath = new Map(all.map((c) => [c.path, c]));
1743
+ const result = { tracked: [], untracked: [], renamed: [] };
1744
+ for (const p of paths) {
1745
+ const change = byPath.get(p);
1746
+ if (!change) continue;
1747
+ if (change.status === "??") {
1748
+ result.untracked.push(p);
1749
+ } else if (change.renamedFrom) {
1750
+ result.renamed.push({ path: p, renamedFrom: change.renamedFrom });
1751
+ } else {
1752
+ result.tracked.push(p);
1753
+ }
1754
+ }
1755
+ return result;
1685
1756
  }
1686
- function defaultOptions(env = process.env) {
1687
- return {
1688
- dir: resolveLogDir(env),
1689
- fileName: DEFAULT_LOG_FILE,
1690
- maxBytes: DEFAULT_MAX_BYTES,
1691
- maxArchives: DEFAULT_MAX_ARCHIVES,
1692
- level: resolveLogLevel(env),
1693
- mirrorConsole: true
1694
- };
1757
+ async function commit(cwd, opts) {
1758
+ const message = opts.message?.trim() ?? "";
1759
+ if (!message) throw new GitError("invalid_message", "commit message must not be empty");
1760
+ if (!Array.isArray(opts.paths) || opts.paths.length === 0) {
1761
+ throw new GitError("no_paths", "commit requires at least one path");
1762
+ }
1763
+ for (const p of opts.paths) assertSafeRelativePath(cwd, p);
1764
+ const { renamed } = await partitionPathsByStatus(cwd, opts.paths);
1765
+ const extraPaths = renamed.map((r) => r.renamedFrom);
1766
+ const allPaths = [...opts.paths, ...extraPaths];
1767
+ await runGit(cwd, ["add", "--", ...allPaths]);
1768
+ await runGit(cwd, ["commit", "-m", message, "--", ...allPaths]);
1769
+ const { stdout } = await runGit(cwd, ["rev-parse", "HEAD"]);
1770
+ return { commit: stdout.trim() };
1695
1771
  }
1696
- class RotatingFileSink {
1697
- constructor(dir, fileName, maxBytes, maxArchives) {
1698
- this.dir = dir;
1699
- this.fileName = fileName;
1700
- this.maxBytes = maxBytes;
1701
- this.maxArchives = maxArchives;
1772
+ async function discard(cwd, opts) {
1773
+ if (!Array.isArray(opts.paths) || opts.paths.length === 0) {
1774
+ throw new GitError("no_paths", "discard requires at least one path");
1702
1775
  }
1703
- currentSize = 0;
1704
- initialized = false;
1705
- queue = Promise.resolve();
1706
- get filePath() {
1707
- return join(this.dir, this.fileName);
1776
+ for (const p of opts.paths) assertSafeRelativePath(cwd, p);
1777
+ const { tracked, untracked, renamed } = await partitionPathsByStatus(cwd, opts.paths);
1778
+ if (tracked.length) {
1779
+ await runGit(cwd, ["restore", "--staged", "--worktree", "--", ...tracked]);
1708
1780
  }
1709
- archivePath(index) {
1710
- return `${this.filePath}.${index}`;
1781
+ if (untracked.length) {
1782
+ await runGit(cwd, ["clean", "-fd", "--", ...untracked]);
1711
1783
  }
1712
- /** Enqueue a line; returns immediately, never throws. */
1713
- write(line) {
1714
- this.queue = this.queue.then(() => this.writeNow(line)).catch(() => {
1715
- });
1784
+ for (const r of renamed) {
1785
+ await runGitRaw(cwd, ["reset", "-q", "HEAD", "--", r.renamedFrom, r.path]);
1786
+ await runGit(cwd, ["restore", "--worktree", "--", r.renamedFrom]);
1787
+ await runGit(cwd, ["clean", "-fd", "--", r.path]);
1716
1788
  }
1717
- /** Resolve once every queued line has been flushed to disk. */
1718
- async flush() {
1719
- await this.queue.catch(() => {
1720
- });
1789
+ }
1790
+ async function stash(cwd, opts) {
1791
+ if (!Array.isArray(opts.paths) || opts.paths.length === 0) {
1792
+ throw new GitError("no_paths", "stash requires at least one path");
1721
1793
  }
1722
- async writeNow(line) {
1794
+ for (const p of opts.paths) assertSafeRelativePath(cwd, p);
1795
+ const { renamed } = await partitionPathsByStatus(cwd, opts.paths);
1796
+ const extraPaths = renamed.map((r) => r.renamedFrom);
1797
+ const allPaths = [...opts.paths, ...extraPaths];
1798
+ const message = opts.message?.trim() || "yorz:stash";
1799
+ await runGit(cwd, ["stash", "push", "--include-untracked", "-m", message, "--", ...allPaths]);
1800
+ }
1801
+ const VALID_ACTIONS = /* @__PURE__ */ new Set(["commit", "discard", "stash"]);
1802
+ function createSpecReviewRoutes(resolveProject) {
1803
+ const app = new Hono();
1804
+ const need = async (c) => {
1805
+ const id = c.req.param("projectId") ?? "";
1806
+ if (!id) return c.json({ error: "projectId required" }, 400);
1807
+ const project = await resolveProject(id);
1808
+ if (!project) return c.json({ error: "project not found" }, 404);
1809
+ return project;
1810
+ };
1811
+ app.post("/projects/:projectId/specs/:id/review", async (c) => {
1812
+ const p = await need(c);
1813
+ if (p instanceof Response) return p;
1814
+ const specId = c.req.param("id");
1815
+ const detail = await p.store.read(specId);
1816
+ if (!detail) return c.json({ error: "spec not found" }, 404);
1817
+ const specRel = `${p.specsDirRelative}/${specId}/spec.md`;
1818
+ const reviewRel = `${p.specsDirRelative}/${specId}/review.md`;
1819
+ const prompt = `请使用 yorz-spec skill 的 "Review / Git Ops 阶段" 流程处理本次 review:
1820
+ - spec 文档:${specRel}
1821
+ - 输出文件:${reviewRel}(追加新二级标题条目,禁止覆盖历史)
1822
+ - 必含 4 节:变更总结 / 影响范围 / 风险提醒 / 变更文件清单
1823
+ - 触发时间使用本机当前时间,格式 \`YYYY-MM-DD HH:mm:ss\`
1824
+ `;
1825
+ const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
1826
+ const handle = await p.sessions.send(sessionId, prompt);
1827
+ return c.json({ runId: handle.runId, sessionId });
1828
+ });
1829
+ app.post("/projects/:projectId/specs/:id/git", async (c) => {
1830
+ const p = await need(c);
1831
+ if (p instanceof Response) return p;
1832
+ const specId = c.req.param("id");
1833
+ const detail = await p.store.read(specId);
1834
+ if (!detail) return c.json({ error: "spec not found" }, 404);
1835
+ let body;
1723
1836
  try {
1724
- await this.ensureInit();
1725
- const bytes = Buffer.byteLength(line, "utf8");
1726
- if (this.currentSize > 0 && this.currentSize + bytes > this.maxBytes) {
1727
- await this.rotate();
1728
- }
1729
- await appendFile(this.filePath, line, "utf8");
1730
- this.currentSize += bytes;
1837
+ body = await c.req.json();
1731
1838
  } catch {
1839
+ return c.json({ error: "invalid JSON body" }, 400);
1732
1840
  }
1733
- }
1734
- /** Lazily create the dir and recover `currentSize` from an existing file. */
1735
- async ensureInit() {
1736
- if (this.initialized) return;
1737
- await mkdir(this.dir, { recursive: true });
1841
+ const action = body && typeof body === "object" ? body.action : null;
1842
+ if (!action || typeof action !== "string" || !VALID_ACTIONS.has(action)) {
1843
+ return c.json({ error: "action must be one of commit | discard | stash" }, 400);
1844
+ }
1845
+ const specRel = `${p.specsDirRelative}/${specId}/spec.md`;
1846
+ const reviewRel = `${p.specsDirRelative}/${specId}/review.md`;
1847
+ const prompt = buildGitOpsPrompt(action, specId, specRel, reviewRel);
1848
+ const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
1849
+ const handle = await p.sessions.send(sessionId, prompt);
1850
+ return c.json({ runId: handle.runId, sessionId });
1851
+ });
1852
+ app.get("/projects/:projectId/specs/:id/review", async (c) => {
1853
+ const p = await need(c);
1854
+ if (p instanceof Response) return p;
1855
+ const specId = c.req.param("id");
1856
+ const detail = await p.store.read(specId);
1857
+ if (!detail) return c.json({ error: "spec not found" }, 404);
1858
+ const file = join(p.specsDir, specId, "review.md");
1859
+ if (!existsSync(file)) return c.json({ text: "" });
1860
+ try {
1861
+ const text = await readFile(file, "utf8");
1862
+ return c.json({ text });
1863
+ } catch {
1864
+ return c.json({ text: "" });
1865
+ }
1866
+ });
1867
+ app.get("/projects/:projectId/specs/:id/debug", async (c) => {
1868
+ const p = await need(c);
1869
+ if (p instanceof Response) return p;
1870
+ const specId = c.req.param("id");
1871
+ const detail = await p.store.read(specId);
1872
+ if (!detail) return c.json({ error: "spec not found" }, 404);
1873
+ const file = join(p.specsDir, specId, "debug.md");
1874
+ if (!existsSync(file)) return c.json({ exists: false, text: "" });
1875
+ try {
1876
+ const text = await readFile(file, "utf8");
1877
+ return c.json({ exists: true, text });
1878
+ } catch {
1879
+ return c.json({ exists: false, text: "" });
1880
+ }
1881
+ });
1882
+ app.get("/projects/:projectId/specs/:id/changes", async (c) => {
1883
+ const p = await need(c);
1884
+ if (p instanceof Response) return p;
1885
+ const specId = c.req.param("id");
1886
+ const detail = await p.store.read(specId);
1887
+ if (!detail) return c.json({ error: "spec not found" }, 404);
1888
+ try {
1889
+ const changes = await listChanges(p.path);
1890
+ return c.json({ changes });
1891
+ } catch (err) {
1892
+ if (err instanceof GitError) return c.json({ error: err.message }, 400);
1893
+ throw err;
1894
+ }
1895
+ });
1896
+ app.post("/projects/:projectId/specs/:id/commit", async (c) => {
1897
+ const p = await need(c);
1898
+ if (p instanceof Response) return p;
1899
+ const specId = c.req.param("id");
1900
+ const detail = await p.store.read(specId);
1901
+ if (!detail) return c.json({ error: "spec not found" }, 404);
1902
+ let body;
1903
+ try {
1904
+ body = await c.req.json();
1905
+ } catch {
1906
+ return c.json({ error: "invalid JSON body" }, 400);
1907
+ }
1908
+ const obj = body && typeof body === "object" ? body : {};
1909
+ const message = typeof obj.message === "string" ? obj.message.trim() : "";
1910
+ const paths = Array.isArray(obj.paths) ? obj.paths.filter((v) => typeof v === "string") : [];
1911
+ if (!message) return c.json({ error: "message must not be empty" }, 400);
1912
+ if (paths.length === 0) return c.json({ error: "paths must not be empty" }, 400);
1913
+ try {
1914
+ const result = await commit(p.path, { message, paths });
1915
+ return c.json(result);
1916
+ } catch (err) {
1917
+ if (err instanceof GitError) return c.json({ error: err.message }, 400);
1918
+ throw err;
1919
+ }
1920
+ });
1921
+ app.post("/projects/:projectId/specs/:id/discard", async (c) => {
1922
+ const p = await need(c);
1923
+ if (p instanceof Response) return p;
1924
+ const specId = c.req.param("id");
1925
+ const detail = await p.store.read(specId);
1926
+ if (!detail) return c.json({ error: "spec not found" }, 404);
1927
+ let body;
1928
+ try {
1929
+ body = await c.req.json();
1930
+ } catch {
1931
+ return c.json({ error: "invalid JSON body" }, 400);
1932
+ }
1933
+ const obj = body && typeof body === "object" ? body : {};
1934
+ const paths = Array.isArray(obj.paths) ? obj.paths.filter((v) => typeof v === "string") : [];
1935
+ if (paths.length === 0) return c.json({ error: "paths must not be empty" }, 400);
1936
+ try {
1937
+ await discard(p.path, { paths });
1938
+ return c.json({ ok: true });
1939
+ } catch (err) {
1940
+ if (err instanceof GitError) return c.json({ error: err.message }, 400);
1941
+ throw err;
1942
+ }
1943
+ });
1944
+ app.post("/projects/:projectId/specs/:id/stash", async (c) => {
1945
+ const p = await need(c);
1946
+ if (p instanceof Response) return p;
1947
+ const specId = c.req.param("id");
1948
+ const detail = await p.store.read(specId);
1949
+ if (!detail) return c.json({ error: "spec not found" }, 404);
1950
+ let body;
1738
1951
  try {
1739
- const st = await stat(this.filePath);
1740
- this.currentSize = st.size;
1952
+ body = await c.req.json();
1741
1953
  } catch {
1742
- this.currentSize = 0;
1743
- }
1744
- this.initialized = true;
1745
- }
1746
- async rotate() {
1747
- if (this.maxArchives <= 0) {
1748
- await writeFile(this.filePath, "", "utf8");
1749
- this.currentSize = 0;
1750
- return;
1954
+ return c.json({ error: "invalid JSON body" }, 400);
1751
1955
  }
1752
- for (let i = this.maxArchives - 1; i >= 1; i--) {
1753
- try {
1754
- await rename(this.archivePath(i), this.archivePath(i + 1));
1755
- } catch {
1756
- }
1956
+ const obj = body && typeof body === "object" ? body : {};
1957
+ const message = typeof obj.message === "string" ? obj.message.trim() : "";
1958
+ const paths = Array.isArray(obj.paths) ? obj.paths.filter((v) => typeof v === "string") : [];
1959
+ if (paths.length === 0) return c.json({ error: "paths must not be empty" }, 400);
1960
+ try {
1961
+ await stash(p.path, { message: message || `yorz:${specId}`, paths });
1962
+ return c.json({ ok: true });
1963
+ } catch (err) {
1964
+ if (err instanceof GitError) return c.json({ error: err.message }, 400);
1965
+ throw err;
1757
1966
  }
1758
- await rename(this.filePath, this.archivePath(1));
1759
- this.currentSize = 0;
1760
- }
1761
- }
1762
- function createSink(options) {
1763
- return new RotatingFileSink(options.dir, options.fileName, options.maxBytes, options.maxArchives);
1764
- }
1765
- function formatMeta(meta) {
1766
- if (!meta) return "";
1767
- const keys = Object.keys(meta);
1768
- if (keys.length === 0) return "";
1769
- try {
1770
- return ` ${JSON.stringify(meta, jsonReplacer)}`;
1771
- } catch {
1772
- return "";
1773
- }
1774
- }
1775
- function jsonReplacer(_key, value) {
1776
- if (value instanceof Error) return { message: value.message, stack: value.stack };
1777
- if (typeof value === "bigint") return value.toString();
1778
- return value;
1967
+ });
1968
+ return app;
1779
1969
  }
1780
- class Logger {
1781
- constructor(core, scope) {
1782
- this.core = core;
1783
- this.scope = scope;
1784
- }
1785
- get level() {
1786
- return this.core.options.level;
1787
- }
1788
- get filePath() {
1789
- return this.core.sink.filePath;
1790
- }
1791
- get dir() {
1792
- return this.core.options.dir;
1793
- }
1794
- /** Derive a logger that prefixes every line with `[scope]`. */
1795
- child(scope) {
1796
- const next = this.scope && this.scope !== "yorz" ? `${this.scope}:${scope}` : scope;
1797
- return new Logger(this.core, next);
1798
- }
1799
- debug(msg, meta) {
1800
- this.emit("debug", msg, meta);
1801
- }
1802
- info(msg, meta) {
1803
- this.emit("info", msg, meta);
1804
- }
1805
- warn(msg, meta) {
1806
- this.emit("warn", msg, meta);
1807
- }
1808
- error(msg, meta) {
1809
- this.emit("error", msg, meta);
1810
- }
1811
- /** Replace options in place; every existing child logger picks the change up. */
1812
- configure(patch) {
1813
- const prev = this.core.options;
1814
- const next = { ...prev, ...patch };
1815
- this.core.options = next;
1816
- const sinkChanged = next.dir !== prev.dir || next.fileName !== prev.fileName || next.maxBytes !== prev.maxBytes || next.maxArchives !== prev.maxArchives;
1817
- if (sinkChanged) this.core.sink = createSink(next);
1818
- }
1819
- async flush() {
1820
- await this.core.sink.flush();
1821
- }
1822
- emit(level, msg, meta) {
1823
- const { options } = this.core;
1824
- if (LEVEL_WEIGHT[level] < LEVEL_WEIGHT[options.level]) return;
1825
- const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] [${level}] [${this.scope}] ${msg}${formatMeta(meta)}
1970
+ function buildGitOpsPrompt(action, specId, specRel, reviewRel) {
1971
+ const base = `请使用 yorz-spec skill 的 "Review / Git Ops 阶段" 流程执行 git 操作:
1972
+ - spec 文档:${specRel}
1973
+ - 最近一次 review:${reviewRel}(如不存在,请先依据 git status/diff 自行判断本次 spec 关联的变更)
1974
+ - spec-id:${specId}
1975
+ `;
1976
+ switch (action) {
1977
+ case "commit":
1978
+ return base + `- 动作:git-commit。请基于 review 报告与 git status,由你自主判断本次 spec 相关的变更文件,执行 \`git add\` + \`git commit\`;commit message 由你生成,不带 scope;禁止 \`git push\` 与 \`git reset --hard\`。
1979
+ `;
1980
+ case "discard":
1981
+ return base + `- 动作:git-discard。请使用 \`git restore --staged --worktree -- <paths>\` 与 \`git clean -fd -- <paths>\` 丢弃 spec 相关的未提交变更;对 untracked 新文件应先在终端输出列表再处理;不要预先 stash 备份;禁止 \`git reset --hard\` 与 \`git push\`。
1982
+ `;
1983
+ case "stash":
1984
+ return base + `- 动作:git-stash。请使用 \`git stash push -m "yorz:${specId}" -- <paths>\` 暂存 spec 相关变更文件;禁止 \`git push\`。
1826
1985
  `;
1827
- if (options.mirrorConsole) mirror(level, line);
1828
- this.core.sink.write(line);
1829
- }
1830
- }
1831
- function mirror(level, line) {
1832
- const text = line.endsWith("\n") ? line.slice(0, -1) : line;
1833
- try {
1834
- if (level === "error") console.error(text);
1835
- else if (level === "warn") console.warn(text);
1836
- else console.log(text);
1837
- } catch {
1838
1986
  }
1839
1987
  }
1840
- function createLogger(patch = {}) {
1841
- const options = { ...defaultOptions(), ...patch };
1842
- return new Logger({ options, sink: createSink(options) }, "yorz");
1843
- }
1844
- let singleton = null;
1845
- function getLogger() {
1846
- if (!singleton) singleton = createLogger();
1847
- return singleton;
1848
- }
1849
- function configureLogger(patch) {
1850
- const logger = getLogger();
1851
- logger.configure(patch);
1852
- return logger;
1853
- }
1854
1988
  const sseLog = () => getLogger().child("sse");
1855
1989
  const HEARTBEAT_INTERVAL_MS = 5e3;
1856
1990
  function attachHeartbeat(stream, intervalMs = HEARTBEAT_INTERVAL_MS) {
@@ -2030,6 +2164,7 @@ class EventsHub {
2030
2164
  }
2031
2165
  async attachTopic(s, topic) {
2032
2166
  if (topic === "projects") return this.attachProjects(s);
2167
+ if (topic === "system-notifications") return this.attachSystemNotifications(s);
2033
2168
  const m = /^project:([^:]+):(.+)$/.exec(topic);
2034
2169
  if (!m) throw new Error(`invalid topic: ${topic}`);
2035
2170
  const pid = m[1];
@@ -2058,6 +2193,15 @@ class EventsHub {
2058
2193
  this.emit(s, "projects", "projects-changed", {});
2059
2194
  });
2060
2195
  }
2196
+ attachSystemNotifications(s) {
2197
+ this.emit(s, "system-notifications", "ready", {});
2198
+ const center = this.deps.systemNotifications;
2199
+ if (!center) return () => {
2200
+ };
2201
+ return center.subscribe(() => {
2202
+ this.emit(s, "system-notifications", "updated", {});
2203
+ });
2204
+ }
2061
2205
  attachSpecsList(s, topic, project) {
2062
2206
  this.emit(s, topic, "ready", {});
2063
2207
  return project.watcher.subscribeList(() => {
@@ -2125,9 +2269,9 @@ class EventsHub {
2125
2269
  });
2126
2270
  }
2127
2271
  }
2128
- function createEventsRoutes(resolveProject, registry, projectsBus) {
2272
+ function createEventsRoutes(resolveProject, registry, projectsBus, systemNotifications) {
2129
2273
  const app = new Hono();
2130
- const hub = new EventsHub({ resolveProject, registry, projectsBus });
2274
+ const hub = new EventsHub({ resolveProject, registry, projectsBus, systemNotifications });
2131
2275
  app.get("/events/stream", (c) => {
2132
2276
  const clientId = c.req.query("clientId")?.trim();
2133
2277
  if (!clientId) {
@@ -2300,12 +2444,12 @@ function normalizeCommands(value) {
2300
2444
  if (!item || typeof item !== "object") continue;
2301
2445
  const obj = item;
2302
2446
  const id = typeof obj.id === "string" ? obj.id.trim() : "";
2303
- const name = typeof obj.name === "string" ? obj.name.trim() : "";
2447
+ const name2 = typeof obj.name === "string" ? obj.name.trim() : "";
2304
2448
  const cli = typeof obj.cli === "string" ? obj.cli.trim() : "";
2305
- if (!id || !name || !cli || seen.has(id)) continue;
2449
+ if (!id || !name2 || !cli || seen.has(id)) continue;
2306
2450
  seen.add(id);
2307
2451
  const createdAt = typeof obj.createdAt === "number" && obj.createdAt > 0 ? obj.createdAt : 0;
2308
- out.push({ id, name, cli, createdAt });
2452
+ out.push({ id, name: name2, cli, createdAt });
2309
2453
  }
2310
2454
  return out;
2311
2455
  }
@@ -2530,12 +2674,12 @@ function createSpecDraftsRoutes(resolveProject) {
2530
2674
  const blob = file;
2531
2675
  const buf = new Uint8Array(await blob.arrayBuffer());
2532
2676
  const mime = blob.type || "";
2533
- const name = blob.name || "attachment";
2677
+ const name2 = blob.name || "attachment";
2534
2678
  if (!classifyMime(mime)) {
2535
2679
  return c.json({ error: `unsupported MIME: ${mime || "(empty)"}` }, 415);
2536
2680
  }
2537
2681
  try {
2538
- const meta = await p.attachments.addAttachment(draftId, { name, mime, data: buf });
2682
+ const meta = await p.attachments.addAttachment(draftId, { name: name2, mime, data: buf });
2539
2683
  return c.json(meta, 201);
2540
2684
  } catch (err) {
2541
2685
  return errorResponse(c, err);
@@ -2570,12 +2714,12 @@ function createSpecDraftsRoutes(resolveProject) {
2570
2714
  } catch {
2571
2715
  return c.json({ error: "invalid JSON body" }, 400);
2572
2716
  }
2573
- const name = body?.name;
2574
- if (typeof name !== "string" || !name.trim()) {
2717
+ const name2 = body?.name;
2718
+ if (typeof name2 !== "string" || !name2.trim()) {
2575
2719
  return c.json({ error: "name required" }, 400);
2576
2720
  }
2577
2721
  try {
2578
- const meta = await p.attachments.renameAttachment(draftId, storedName, name);
2722
+ const meta = await p.attachments.renameAttachment(draftId, storedName, name2);
2579
2723
  return c.json(meta);
2580
2724
  } catch (err) {
2581
2725
  return errorResponse(c, err);
@@ -2917,7 +3061,7 @@ const RUN_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
2917
3061
  function runLogRelPath(runId) {
2918
3062
  return `.yorz/tmp/commands/${runId}.log`;
2919
3063
  }
2920
- const log$2 = () => getLogger().child("commands");
3064
+ const log$3 = () => getLogger().child("commands");
2921
3065
  const TAIL_POLL_MS = 200;
2922
3066
  const STOP_GRACE_MS = 2e3;
2923
3067
  class CommandNotFoundError extends Error {
@@ -2997,7 +3141,7 @@ class LogTailer {
2997
3141
  }
2998
3142
  }
2999
3143
  } catch (err) {
3000
- log$2().warn("tail read failed", { file: this.file, err });
3144
+ log$3().warn("tail read failed", { file: this.file, err });
3001
3145
  } finally {
3002
3146
  if (fd !== null) closeSync(fd);
3003
3147
  }
@@ -3032,8 +3176,8 @@ class CommandManager {
3032
3176
  const cfg = await loadProjectConfig(this.projectPath);
3033
3177
  return cfg.commands;
3034
3178
  }
3035
- async addDef(name, cli) {
3036
- const trimmedName = name.trim();
3179
+ async addDef(name2, cli) {
3180
+ const trimmedName = name2.trim();
3037
3181
  const trimmedCli = cli.trim();
3038
3182
  if (!trimmedName) throw new Error("name is required");
3039
3183
  if (!trimmedCli) throw new Error("cli is required");
@@ -3101,7 +3245,7 @@ class CommandManager {
3101
3245
  signal: null,
3102
3246
  logFile: logRel
3103
3247
  };
3104
- log$2().error("failed to open command log", { runId, logAbs, err });
3248
+ log$3().error("failed to open command log", { runId, logAbs, err });
3105
3249
  await this.store.upsert(failed);
3106
3250
  this.notifyRuns();
3107
3251
  return failed;
@@ -3140,7 +3284,7 @@ class CommandManager {
3140
3284
  void this.handleExit(runId, signal ? "killed" : "exited", code, signal ?? null);
3141
3285
  });
3142
3286
  child.on("error", (err) => {
3143
- log$2().warn("command child error", { runId, err });
3287
+ log$3().warn("command child error", { runId, err });
3144
3288
  this.children.delete(runId);
3145
3289
  void this.handleExit(runId, "failed", null, null);
3146
3290
  });
@@ -3155,7 +3299,7 @@ class CommandManager {
3155
3299
  if (settled) return settled;
3156
3300
  }
3157
3301
  this.notifyRuns();
3158
- log$2().info("command started", { runId, commandId, pid: run.pid, cli: def.cli });
3302
+ log$3().info("command started", { runId, commandId, pid: run.pid, cli: def.cli });
3159
3303
  return run;
3160
3304
  } finally {
3161
3305
  try {
@@ -3272,7 +3416,7 @@ class CommandManager {
3272
3416
  if (run.status === "running") {
3273
3417
  if (run.pid > 0 && isAlive(run.pid)) {
3274
3418
  killGroup(run.pid, "SIGKILL");
3275
- log$2().warn("reaped orphan command process", { runId: run.runId, pid: run.pid });
3419
+ log$3().warn("reaped orphan command process", { runId: run.runId, pid: run.pid });
3276
3420
  }
3277
3421
  kept.push({ ...run, status: "killed", endedAt: run.endedAt ?? now, exitCode: null });
3278
3422
  continue;
@@ -3292,7 +3436,7 @@ class CommandManager {
3292
3436
  killGroup(run.pid, "SIGTERM");
3293
3437
  const exited = await waitForExit(run.pid, STOP_GRACE_MS);
3294
3438
  if (!exited) {
3295
- log$2().warn("command did not exit on SIGTERM, escalating", {
3439
+ log$3().warn("command did not exit on SIGTERM, escalating", {
3296
3440
  runId: run.runId,
3297
3441
  pid: run.pid
3298
3442
  });
@@ -3323,7 +3467,7 @@ class CommandManager {
3323
3467
  await this.store.upsert(next);
3324
3468
  this.notifyRuns();
3325
3469
  this.notifyRun(next);
3326
- log$2().info("command ended", { runId, status, exitCode, signal });
3470
+ log$3().info("command ended", { runId, status, exitCode, signal });
3327
3471
  }
3328
3472
  notifyRuns() {
3329
3473
  if (this.runsSubscribers.size === 0) return;
@@ -3545,13 +3689,43 @@ function createCommandsRoutes(resolveProject) {
3545
3689
  function parseDefBody(value) {
3546
3690
  if (!value || typeof value !== "object") return { error: "body must be an object" };
3547
3691
  const obj = value;
3548
- const name = typeof obj.name === "string" ? obj.name.trim() : "";
3692
+ const name2 = typeof obj.name === "string" ? obj.name.trim() : "";
3549
3693
  const cli = typeof obj.cli === "string" ? obj.cli.trim() : "";
3550
- if (!name) return { error: "name required" };
3551
- if (name.length > MAX_NAME_LEN) return { error: `name too long (max ${MAX_NAME_LEN})` };
3694
+ if (!name2) return { error: "name required" };
3695
+ if (name2.length > MAX_NAME_LEN) return { error: `name too long (max ${MAX_NAME_LEN})` };
3552
3696
  if (!cli) return { error: "cli required" };
3553
3697
  if (cli.length > MAX_CLI_LEN) return { error: `cli too long (max ${MAX_CLI_LEN})` };
3554
- return { name, cli };
3698
+ return { name: name2, cli };
3699
+ }
3700
+ function createSystemNotificationsRoutes(center) {
3701
+ const app = new Hono();
3702
+ app.get("/system-notifications", (c) => c.json(center.list()));
3703
+ app.delete("/system-notifications/:id", (c) => {
3704
+ const ok = center.delete(c.req.param("id"));
3705
+ if (!ok) return c.json({ error: "notification not found" }, 404);
3706
+ return c.json({ ok: true });
3707
+ });
3708
+ app.post("/system-notifications/:id/update", async (c) => {
3709
+ try {
3710
+ const notification = await center.update(c.req.param("id"));
3711
+ return c.json(notification);
3712
+ } catch (err) {
3713
+ const message = err instanceof Error ? err.message : String(err);
3714
+ const status = message === "notification not found" ? 404 : 400;
3715
+ return c.json({ error: message }, status);
3716
+ }
3717
+ });
3718
+ app.post("/system-notifications/:id/restart", async (c) => {
3719
+ try {
3720
+ const notification = await center.restart(c.req.param("id"));
3721
+ return c.json(notification);
3722
+ } catch (err) {
3723
+ const message = err instanceof Error ? err.message : String(err);
3724
+ const status = message === "notification not found" ? 404 : 400;
3725
+ return c.json({ error: message }, status);
3726
+ }
3727
+ });
3728
+ return app;
3555
3729
  }
3556
3730
  const MIME = {
3557
3731
  ".html": "text/html; charset=utf-8",
@@ -3716,6 +3890,7 @@ class WorktreeManager {
3716
3890
  path: wtPath,
3717
3891
  baseRef
3718
3892
  });
3893
+ this.onProjectsChanged?.();
3719
3894
  return { entry: updated, branch, path: wtPath, baseRef };
3720
3895
  }
3721
3896
  /** Update the `specId` on a worktree entry once the spec has been created. */
@@ -3817,9 +3992,9 @@ class WorktreeManager {
3817
3992
  }
3818
3993
  async collectConflictReport(mainPath) {
3819
3994
  const unmerged = await runGitChecked(mainPath, ["diff", "--name-only", "--diff-filter=U"]);
3820
- const files = unmerged.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
3995
+ const files2 = unmerged.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
3821
3996
  const commitsByFile = {};
3822
- for (const file of files) {
3997
+ for (const file of files2) {
3823
3998
  const log2 = await runGitRaw(mainPath, [
3824
3999
  "log",
3825
4000
  "--since=30 days ago",
@@ -3838,7 +4013,7 @@ class WorktreeManager {
3838
4013
  "--date=short"
3839
4014
  ]);
3840
4015
  return {
3841
- files,
4016
+ files: files2,
3842
4017
  commitsByFile,
3843
4018
  recentMerges: parseCommitLines(merges.stdout)
3844
4019
  };
@@ -3892,10 +4067,10 @@ class WorktreeManager {
3892
4067
  }
3893
4068
  async function detectDefaultBranch(mainPath) {
3894
4069
  const res = await runGitChecked(mainPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
3895
- const name = res.stdout.trim();
3896
- if (!name || name === "HEAD")
4070
+ const name2 = res.stdout.trim();
4071
+ if (!name2 || name2 === "HEAD")
3897
4072
  throw new GitError("detached_head", "main project is in detached HEAD; cannot base worktree");
3898
- return name;
4073
+ return name2;
3899
4074
  }
3900
4075
  async function uniqueBranch(mainPath, base) {
3901
4076
  let candidate = base;
@@ -4069,6 +4244,9 @@ function createApp(opts) {
4069
4244
  }
4070
4245
  });
4071
4246
  api.route("/", createProjectRoutes(opts.registry, worktreeManager));
4247
+ if (opts.systemNotifications) {
4248
+ api.route("/", createSystemNotificationsRoutes(opts.systemNotifications));
4249
+ }
4072
4250
  api.route("/", createGlobalConfigRoutes(opts.registry.configPath()));
4073
4251
  api.route("/", createProjectConfigRoutes(opts.registry));
4074
4252
  api.route("/", createSpecsRoutes(resolveProject));
@@ -4078,7 +4256,10 @@ function createApp(opts) {
4078
4256
  api.route("/", createWorktreeRoutes(opts.registry, worktreeManager));
4079
4257
  api.route("/", createProjectFilesRoutes(resolveProject));
4080
4258
  api.route("/", createCommandsRoutes(resolveProject));
4081
- api.route("/", createEventsRoutes(resolveProject, opts.registry, projectsBus));
4259
+ api.route(
4260
+ "/",
4261
+ createEventsRoutes(resolveProject, opts.registry, projectsBus, opts.systemNotifications)
4262
+ );
4082
4263
  app.route("/api", api);
4083
4264
  app.route("/", createStaticRoutes(opts.guiRoot));
4084
4265
  app.onError((err, c) => {
@@ -4225,8 +4406,8 @@ class SpecStore {
4225
4406
  if (kind !== "feat" && kind !== "refct" && kind !== "fix") {
4226
4407
  throw new Error("kind must be feat | refct | fix");
4227
4408
  }
4228
- const description = (input.description ?? "").trim();
4229
- if (!description) throw new Error("description required");
4409
+ const description2 = (input.description ?? "").trim();
4410
+ if (!description2) throw new Error("description required");
4230
4411
  const sectionPath = input.sectionPath?.trim() ?? "";
4231
4412
  const quote = input.quote?.trim() ?? "";
4232
4413
  if (quote.length > 500) throw new Error("quote too long (max 500)");
@@ -4239,10 +4420,10 @@ class SpecStore {
4239
4420
  updated_at: this.nowDateTime(),
4240
4421
  summary: existing.summary
4241
4422
  };
4242
- const firstLine = description.split(/\r?\n/)[0].slice(0, 80);
4423
+ const firstLine = description2.split(/\r?\n/)[0].slice(0, 80);
4243
4424
  const stamp = this.timestamp();
4244
4425
  const lines = [`- [open] [${kind}] ${stamp} | ${firstLine}`];
4245
- lines.push(` - 描述:${description}`);
4426
+ lines.push(` - 描述:${description2}`);
4246
4427
  if (sectionPath) lines.push(` - 引用:${sectionPath}`);
4247
4428
  if (quote) lines.push(` - 引用原文:> ${quote}`);
4248
4429
  const entry = lines.join("\n");
@@ -4315,9 +4496,9 @@ class SpecStore {
4315
4496
  if (!existsSync(dir)) return;
4316
4497
  await rm(dir, { recursive: true, force: true });
4317
4498
  }
4318
- async allocateId(type, summary) {
4499
+ async allocateId(type2, summary) {
4319
4500
  const slug = kebab(summary);
4320
- const base = `${this.todayCompact()}.${type}.${slug}`;
4501
+ const base = `${this.todayCompact()}.${type2}.${slug}`;
4321
4502
  let id = base;
4322
4503
  let n = 2;
4323
4504
  while (existsSync(join(this.root, id))) {
@@ -4920,10 +5101,10 @@ class CodexAdapter {
4920
5101
  }
4921
5102
  /** Walk ~/.codex/sessions, keep rollouts whose session_meta.cwd matches this project. */
4922
5103
  async listSessions() {
4923
- const files = await this.walkRollouts();
5104
+ const files2 = await this.walkRollouts();
4924
5105
  const titleIndex = await this.readSessionIndex();
4925
5106
  const out = [];
4926
- for (const file of files) {
5107
+ for (const file of files2) {
4927
5108
  const meta = await this.readMeta(file, titleIndex);
4928
5109
  if (!meta || meta.cwd !== this.cwd) continue;
4929
5110
  out.push({
@@ -5047,11 +5228,11 @@ class CodexAdapter {
5047
5228
  });
5048
5229
  }
5049
5230
  async findFileById(id) {
5050
- const files = await this.walkRollouts();
5051
- const match = files.find((f) => f.includes(id));
5231
+ const files2 = await this.walkRollouts();
5232
+ const match = files2.find((f) => f.includes(id));
5052
5233
  if (match) return match;
5053
5234
  const titleIndex = await this.readSessionIndex();
5054
- for (const f of files) {
5235
+ for (const f of files2) {
5055
5236
  const meta = await this.readMeta(f, titleIndex);
5056
5237
  if (meta?.id === id) return f;
5057
5238
  }
@@ -5227,6 +5408,9 @@ function summarizePromptForTitle(prompt) {
5227
5408
  if (!cleaned) return "";
5228
5409
  return cleaned.length > TITLE_MAX_LENGTH ? `${cleaned.slice(0, TITLE_MAX_LENGTH - 1)}…` : cleaned;
5229
5410
  }
5411
+ function truncateTitle(title) {
5412
+ return title.length > TITLE_MAX_LENGTH ? `${title.slice(0, TITLE_MAX_LENGTH - 1)}…` : title;
5413
+ }
5230
5414
  class SessionManager {
5231
5415
  constructor(cwd, defaultKind, store, opts = {}) {
5232
5416
  this.cwd = cwd;
@@ -5284,6 +5468,13 @@ class SessionManager {
5284
5468
  if (existing) return existing;
5285
5469
  return this.createSession(void 0, specId, specId);
5286
5470
  }
5471
+ async bindSessionToSpec(sessionId, specId, title) {
5472
+ const ok = await this.store.bindSpec(sessionId, specId);
5473
+ if (!ok) return false;
5474
+ const trimmed = title?.trim();
5475
+ if (trimmed) await this.store.updateTitle(sessionId, truncateTitle(trimmed));
5476
+ return true;
5477
+ }
5287
5478
  async listSessions() {
5288
5479
  const indexed = await this.store.list();
5289
5480
  const byId = new Map(indexed.map((s) => [s.id, s]));
@@ -5407,7 +5598,7 @@ class SessionManager {
5407
5598
  this.setRunning(currentSid, false);
5408
5599
  void Promise.resolve(this.opts.onSessionEnd?.(currentSid)).catch(() => {
5409
5600
  });
5410
- emitter.emit("done");
5601
+ emitter.emit("done", currentSid);
5411
5602
  }
5412
5603
  })();
5413
5604
  return {
@@ -5527,6 +5718,15 @@ class SessionStore {
5527
5718
  entry.updatedAt = Date.now();
5528
5719
  await this.persist();
5529
5720
  }
5721
+ async bindSpec(id, specId) {
5722
+ const items = await this.load();
5723
+ const entry = items.find((s) => s.id === id);
5724
+ if (!entry) return false;
5725
+ entry.specId = specId;
5726
+ entry.updatedAt = Date.now();
5727
+ await this.persist();
5728
+ return true;
5729
+ }
5530
5730
  async touch(id) {
5531
5731
  const items = await this.load();
5532
5732
  const entry = items.find((s) => s.id === id);
@@ -5559,8 +5759,8 @@ async function defaultRunCommand(cmd, args) {
5559
5759
  });
5560
5760
  }
5561
5761
  function formatBannerTitle(projectName) {
5562
- const name = projectName?.trim();
5563
- return name ? `YorZ · ${name}` : "YorZ";
5762
+ const name2 = projectName?.trim();
5763
+ return name2 ? `YorZ · ${name2}` : "YorZ";
5564
5764
  }
5565
5765
  function appleScriptString(value) {
5566
5766
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
@@ -5632,12 +5832,12 @@ class ProjectRegistry {
5632
5832
  const config = await loadGlobalConfig(this.globalConfigPath);
5633
5833
  const items = [];
5634
5834
  for (const p of config.projects) {
5635
- const name = basename(p.path);
5835
+ const name2 = basename(p.path);
5636
5836
  const fallback = await maxSpecUpdatedAt(p.path);
5637
5837
  const sortKey = p.lastActivityAt ?? fallback ?? "";
5638
5838
  const item = {
5639
5839
  id: p.id,
5640
- name,
5840
+ name: name2,
5641
5841
  path: p.path,
5642
5842
  lastActivityAt: p.lastActivityAt,
5643
5843
  sortKey
@@ -5646,8 +5846,8 @@ class ProjectRegistry {
5646
5846
  items.push(item);
5647
5847
  }
5648
5848
  items.sort((a, b) => a.sortKey < b.sortKey ? 1 : a.sortKey > b.sortKey ? -1 : 0);
5649
- return items.map(({ id, name, path, lastActivityAt, worktree }) => {
5650
- const out = { id, name, path, lastActivityAt };
5849
+ return items.map(({ id, name: name2, path, lastActivityAt, worktree }) => {
5850
+ const out = { id, name: name2, path, lastActivityAt };
5651
5851
  if (worktree) out.worktree = worktree;
5652
5852
  return out;
5653
5853
  });
@@ -5738,6 +5938,7 @@ class ProjectRegistry {
5738
5938
  onSessionEnd: notifySessionEnded
5739
5939
  });
5740
5940
  const commands = getCommandManager(input.path);
5941
+ const stopChatDebugCleanup = scheduleChatDebugCleanup(input.path);
5741
5942
  let closed = false;
5742
5943
  const instance = {
5743
5944
  id: input.id,
@@ -5752,6 +5953,7 @@ class ProjectRegistry {
5752
5953
  async close() {
5753
5954
  if (closed) return;
5754
5955
  closed = true;
5956
+ stopChatDebugCleanup();
5755
5957
  await watcher.close();
5756
5958
  await sessions.dispose().catch(() => {
5757
5959
  });
@@ -5800,6 +6002,182 @@ async function maxSpecUpdatedAt(projectPath) {
5800
6002
  return null;
5801
6003
  }
5802
6004
  }
6005
+ const VERSION_NOTIFICATION_ID = "version-update";
6006
+ const VERSION_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1e3;
6007
+ const log$2 = () => getLogger().child("system-notifications");
6008
+ class SystemNotificationCenter {
6009
+ constructor(opts = {}) {
6010
+ this.opts = opts;
6011
+ }
6012
+ items = /* @__PURE__ */ new Map();
6013
+ listeners = /* @__PURE__ */ new Set();
6014
+ timer = null;
6015
+ checking = false;
6016
+ list() {
6017
+ return [...this.items.values()].sort((a, b) => b.updatedAt - a.updatedAt);
6018
+ }
6019
+ delete(id) {
6020
+ const deleted = this.items.delete(id);
6021
+ if (deleted) this.emit();
6022
+ return deleted;
6023
+ }
6024
+ subscribe(listener) {
6025
+ this.listeners.add(listener);
6026
+ return () => this.listeners.delete(listener);
6027
+ }
6028
+ upsertVersionUpdate(info) {
6029
+ const now = Date.now();
6030
+ const current = this.items.get(VERSION_NOTIFICATION_ID);
6031
+ const next = {
6032
+ id: VERSION_NOTIFICATION_ID,
6033
+ kind: "version-update",
6034
+ title: "YorZ update available",
6035
+ message: `YorZ ${info.latest} is available. Current version: ${info.current}.`,
6036
+ createdAt: current?.createdAt ?? now,
6037
+ updatedAt: now,
6038
+ action: current?.action === "restart-ready" ? "restart-ready" : "update-available",
6039
+ metadata: { currentVersion: info.current, latestVersion: info.latest }
6040
+ };
6041
+ this.items.set(next.id, next);
6042
+ this.emit();
6043
+ return next;
6044
+ }
6045
+ async update(id) {
6046
+ const item = this.requireVersionNotification(id);
6047
+ this.setAction(item.id, "updating");
6048
+ try {
6049
+ await (this.opts.runUpdate ?? defaultRunUpdate)();
6050
+ return this.setAction(item.id, "restart-ready");
6051
+ } catch (err) {
6052
+ const current = this.requireVersionNotification(id);
6053
+ const next = {
6054
+ ...current,
6055
+ action: "update-available",
6056
+ updatedAt: Date.now(),
6057
+ metadata: {
6058
+ ...current.metadata ?? {},
6059
+ error: err instanceof Error ? err.message : String(err)
6060
+ }
6061
+ };
6062
+ this.items.set(id, next);
6063
+ this.emit();
6064
+ throw err;
6065
+ }
6066
+ }
6067
+ async restart(id) {
6068
+ const item = this.requireVersionNotification(id);
6069
+ if (item.action !== "restart-ready") throw new Error("restart is not ready");
6070
+ await (this.opts.runRestart ?? defaultRunRestart)();
6071
+ return item;
6072
+ }
6073
+ startVersionChecks(intervalMs = VERSION_CHECK_INTERVAL_MS) {
6074
+ if (this.timer) return;
6075
+ void this.checkVersion();
6076
+ this.timer = setInterval(() => void this.checkVersion(), intervalMs);
6077
+ this.timer.unref?.();
6078
+ }
6079
+ stopVersionChecks() {
6080
+ if (!this.timer) return;
6081
+ clearInterval(this.timer);
6082
+ this.timer = null;
6083
+ }
6084
+ async checkVersion() {
6085
+ if (this.checking) return;
6086
+ this.checking = true;
6087
+ try {
6088
+ const latest = await (this.opts.fetchLatestVersion ?? fetchLatestVersion)();
6089
+ if (!latest) return;
6090
+ const current = pkg.version;
6091
+ if (compareVersions(latest, current) > 0) {
6092
+ this.upsertVersionUpdate({ current, latest });
6093
+ }
6094
+ } catch (err) {
6095
+ log$2().warn("version check failed", { err });
6096
+ } finally {
6097
+ this.checking = false;
6098
+ }
6099
+ }
6100
+ requireVersionNotification(id) {
6101
+ const item = this.items.get(id);
6102
+ if (!item) throw new Error("notification not found");
6103
+ if (item.kind !== "version-update") throw new Error("notification has no update action");
6104
+ return item;
6105
+ }
6106
+ setAction(id, action) {
6107
+ const item = this.requireVersionNotification(id);
6108
+ const next = { ...item, action, updatedAt: Date.now() };
6109
+ this.items.set(id, next);
6110
+ this.emit();
6111
+ return next;
6112
+ }
6113
+ emit() {
6114
+ for (const listener of this.listeners) {
6115
+ try {
6116
+ listener();
6117
+ } catch {
6118
+ }
6119
+ }
6120
+ }
6121
+ }
6122
+ function compareVersions(a, b) {
6123
+ const pa = parseVersion(a);
6124
+ const pb = parseVersion(b);
6125
+ if (!pa || !pb) return 0;
6126
+ for (let i = 0; i < 3; i++) {
6127
+ const diff = pa[i] - pb[i];
6128
+ if (diff !== 0) return diff > 0 ? 1 : -1;
6129
+ }
6130
+ return 0;
6131
+ }
6132
+ function resolveGlobalInstallCommand(env = process.env) {
6133
+ const source = `${env.npm_config_user_agent ?? ""} ${env.npm_execpath ?? ""}`.toLowerCase();
6134
+ const manager = source.includes("pnpm") ? "pnpm" : source.includes("bun") ? "bun" : source.includes("yarn") ? "yarn" : "npm";
6135
+ switch (manager) {
6136
+ case "pnpm":
6137
+ return { cmd: "pnpm", args: ["add", "-g", "@yorz/cli@latest"] };
6138
+ case "bun":
6139
+ return { cmd: "bun", args: ["add", "-g", "@yorz/cli@latest"] };
6140
+ case "yarn":
6141
+ return { cmd: "yarn", args: ["global", "add", "@yorz/cli@latest"] };
6142
+ default:
6143
+ return { cmd: "npm", args: ["install", "-g", "@yorz/cli@latest"] };
6144
+ }
6145
+ }
6146
+ function parseVersion(value) {
6147
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value.trim());
6148
+ if (!m) return null;
6149
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
6150
+ }
6151
+ async function fetchLatestVersion() {
6152
+ const res = await fetch("https://registry.npmjs.org/@yorz/cli/latest");
6153
+ if (!res.ok) throw new Error(`npm registry returned ${res.status}`);
6154
+ const body = await res.json();
6155
+ return typeof body.version === "string" ? body.version : null;
6156
+ }
6157
+ function defaultRunUpdate() {
6158
+ const install2 = resolveGlobalInstallCommand();
6159
+ return runProcess(install2.cmd, install2.args);
6160
+ }
6161
+ function defaultRunRestart() {
6162
+ const entry = process.argv[1];
6163
+ if (!entry) throw new Error("Cannot resolve CLI entrypoint for restart");
6164
+ const child = spawn(process.execPath, [entry, "serve", "restart"], {
6165
+ detached: true,
6166
+ stdio: "ignore"
6167
+ });
6168
+ child.unref();
6169
+ return Promise.resolve();
6170
+ }
6171
+ function runProcess(cmd, args) {
6172
+ return new Promise((resolve2, reject) => {
6173
+ const child = spawn(cmd, args, { stdio: "ignore" });
6174
+ child.on("error", reject);
6175
+ child.on("exit", (code, signal) => {
6176
+ if (code === 0) resolve2();
6177
+ else reject(new Error(`${cmd} exited with ${code ?? signal ?? "unknown"}`));
6178
+ });
6179
+ });
6180
+ }
5803
6181
  const DEFAULT_PORT = 7423;
5804
6182
  const MAX_PORT_TRIES = 10;
5805
6183
  const DEFAULT_HOST = "127.0.0.1";
@@ -5817,7 +6195,9 @@ async function start(opts = {}) {
5817
6195
  }
5818
6196
  }
5819
6197
  const projects = await registry.list();
5820
- const app = createApp({ registry, guiRoot: opts.guiRoot });
6198
+ const systemNotifications = new SystemNotificationCenter();
6199
+ systemNotifications.startVersionChecks();
6200
+ const app = createApp({ registry, guiRoot: opts.guiRoot, systemNotifications });
5821
6201
  const host = opts.host?.trim() || DEFAULT_HOST;
5822
6202
  const port = await listen(app.fetch, opts.port ?? DEFAULT_PORT, host);
5823
6203
  const url = `http://localhost:${port.port}/`;
@@ -5849,7 +6229,9 @@ async function start(opts = {}) {
5849
6229
  url,
5850
6230
  port: port.port,
5851
6231
  registry,
6232
+ systemNotifications,
5852
6233
  async close() {
6234
+ systemNotifications.stopVersionChecks();
5853
6235
  await stopAllCommandManagers();
5854
6236
  await registry.closeAll();
5855
6237
  await new Promise((resolve2, reject) => {
@@ -5901,8 +6283,8 @@ async function listen(fetchHandler, preferredPort, hostname) {
5901
6283
  function listLanUrls(port) {
5902
6284
  const urls = [];
5903
6285
  const ifaces = networkInterfaces();
5904
- for (const name of Object.keys(ifaces)) {
5905
- for (const info of ifaces[name] ?? []) {
6286
+ for (const name2 of Object.keys(ifaces)) {
6287
+ for (const info of ifaces[name2] ?? []) {
5906
6288
  if (info.family === "IPv4" && !info.internal) {
5907
6289
  urls.push(`http://${info.address}:${port}/`);
5908
6290
  }
@@ -5947,6 +6329,7 @@ const DEFAULT_SERVE_PORT = 7423;
5947
6329
  const RUNTIME_FILE = "runtime.json";
5948
6330
  const START_LOCK_DIR = "serve.lock";
5949
6331
  const START_WAIT_MS = 5e3;
6332
+ const RESTART_DELAY_MS = 500;
5950
6333
  function backgroundStdio() {
5951
6334
  try {
5952
6335
  const dir = resolveLogDir();
@@ -6057,6 +6440,33 @@ function backgroundArgs(opts) {
6057
6440
  args.push("--record-runtime");
6058
6441
  return args;
6059
6442
  }
6443
+ function restartWorkerArgs(opts) {
6444
+ const args = ["serve", "restart", "--worker"];
6445
+ if (opts.port !== void 0) args.push("--port", String(opts.port));
6446
+ if (opts.host !== void 0) args.push("--host", opts.host);
6447
+ if (opts.open) args.push("--open");
6448
+ if (opts.cwd) args.push("--cwd", opts.cwd);
6449
+ if (opts.noRegisterCwd) args.push("--no-register-cwd");
6450
+ args.push("--skip-skill-check");
6451
+ return args;
6452
+ }
6453
+ async function runRestartServe(opts = {}) {
6454
+ silenceConsoleMirror();
6455
+ if (opts.worker) {
6456
+ await sleep(RESTART_DELAY_MS);
6457
+ await runStopServe();
6458
+ await runServe({ ...opts, foreground: false, skipSkillCheck: opts.skipSkillCheck ?? true });
6459
+ return;
6460
+ }
6461
+ const entry = process.argv[1];
6462
+ if (!entry) throw new Error("Cannot resolve CLI entrypoint for restart");
6463
+ const child = spawn(process.execPath, [entry, ...restartWorkerArgs(opts)], {
6464
+ detached: true,
6465
+ stdio: "ignore"
6466
+ });
6467
+ child.unref();
6468
+ console.log(`YorZ Service restart scheduled (pid=${child.pid ?? "unknown"}).`);
6469
+ }
6060
6470
  async function ensureSkillsInstalledWithLog(cwd) {
6061
6471
  const results = await ensureSkillsInstalled({ home: homedir(), cwd });
6062
6472
  for (const r of results) {
@@ -6582,13 +6992,13 @@ const headingNumbering = {
6582
6992
  }
6583
6993
  };
6584
6994
  const headingRules = [headingH1Single, headingSectionLevel, headingNumbering];
6585
- function findSection(ctx, name) {
6995
+ function findSection(ctx, name2) {
6586
6996
  const headings = collectHeadings(ctx);
6587
6997
  for (let i = 0; i < headings.length; i += 1) {
6588
6998
  const h = headings[i];
6589
6999
  if (h.level !== 2) continue;
6590
7000
  const bare = stripHeadingNumber(h.text);
6591
- if (bare !== name) continue;
7001
+ if (bare !== name2) continue;
6592
7002
  const next = headings.slice(i + 1).find((x) => x.level === 2);
6593
7003
  return { start: h.line, end: next ? next.line - 1 : ctx.rawLines.length };
6594
7004
  }
@@ -6849,9 +7259,9 @@ async function initMermaid() {
6849
7259
  pretendToBeVisual: true
6850
7260
  });
6851
7261
  const win = dom.window;
6852
- const define = (name, value) => {
6853
- if (name in globalThis) return;
6854
- Object.defineProperty(globalThis, name, { value, writable: true, configurable: true });
7262
+ const define = (name2, value) => {
7263
+ if (name2 in globalThis) return;
7264
+ Object.defineProperty(globalThis, name2, { value, writable: true, configurable: true });
6855
7265
  };
6856
7266
  define("window", win);
6857
7267
  define("document", win.document);
@@ -7286,8 +7696,8 @@ async function lintReviewMd(raw, opts = {}) {
7286
7696
  }
7287
7697
  async function lintFile(path, opts = {}) {
7288
7698
  const raw = await readFile(path, "utf8");
7289
- const name = basename$1(path).toLowerCase();
7290
- if (name === "review.md") return lintReviewMd(raw, { filePath: path, ...opts });
7699
+ const name2 = basename$1(path).toLowerCase();
7700
+ if (name2 === "review.md") return lintReviewMd(raw, { filePath: path, ...opts });
7291
7701
  return lintSpecMd(raw, { filePath: path, ...opts });
7292
7702
  }
7293
7703
  [...new Set([...SPEC_RULES, ...REVIEW_RULES].map((r) => r.id))];
@@ -7457,6 +7867,29 @@ serveCmd.command("stop").description("Stop the background YorZ Service.").action
7457
7867
  const result = await runStopServe();
7458
7868
  console.log(result.message);
7459
7869
  });
7870
+ serveCmd.command("restart").description("Restart the background YorZ Service.").option("-p, --port <port>", "port to listen on", "7423").option(
7871
+ "--host <address>",
7872
+ "bind address (default: 127.0.0.1; the service runs shell commands, so exposing it to the network is opt-in)"
7873
+ ).option("--open", "open the GUI in the default browser", false).option(
7874
+ "--cwd <path>",
7875
+ "directory to auto-register when it contains .yorz/ (default: process.cwd())"
7876
+ ).option("--no-register-cwd", "do not auto-register the current directory").addOption(new Option("--worker", "internal: run detached restart worker").hideHelp()).addOption(
7877
+ new Option("--skip-skill-check", "internal: skip the skill install/update check").hideHelp()
7878
+ ).action(async (opts) => {
7879
+ const port = opts.port === void 0 ? void 0 : Number.parseInt(opts.port, 10);
7880
+ if (port !== void 0 && (!Number.isFinite(port) || port < 0)) {
7881
+ throw new Error(`Invalid --port: ${opts.port}`);
7882
+ }
7883
+ await runRestartServe({
7884
+ port,
7885
+ host: opts.host,
7886
+ open: opts.open,
7887
+ cwd: opts.cwd,
7888
+ noRegisterCwd: opts.registerCwd === false,
7889
+ skipSkillCheck: opts.skipSkillCheck === true,
7890
+ worker: opts.worker === true
7891
+ });
7892
+ });
7460
7893
  program.parseAsync(process.argv).catch((err) => {
7461
7894
  console.error(`error: ${err.message}`);
7462
7895
  process.exit(1);