@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.
- package/README.md +5 -2
- package/dist/cli/index.js +1600 -1167
- package/dist/gui/assets/{abnfDiagram-VRR7QNED-C1LflvIY.js → abnfDiagram-VRR7QNED-CuRplRPV.js} +1 -1
- package/dist/gui/assets/{arc-CLVcmGRI.js → arc-l8OUkR61.js} +1 -1
- package/dist/gui/assets/{architectureDiagram-ZJ3FMSHR-CshNMQ27.js → architectureDiagram-ZJ3FMSHR-CBUrMLYH.js} +1 -1
- package/dist/gui/assets/{blockDiagram-677ZJIJ3-D5dDqKKl.js → blockDiagram-677ZJIJ3-B0h7xBKD.js} +1 -1
- package/dist/gui/assets/{c4Diagram-LMCZKHZV-DRVpuaAe.js → c4Diagram-LMCZKHZV-8O5EPgBt.js} +1 -1
- package/dist/gui/assets/channel-DfOs8mBK.js +1 -0
- package/dist/gui/assets/{chunk-2Q5K7J3B-CyfpeX3b.js → chunk-2Q5K7J3B-Bljgbhrq.js} +1 -1
- package/dist/gui/assets/{chunk-32BRIVSS-Bpb0lkX5.js → chunk-32BRIVSS-CWu7ELew.js} +1 -1
- package/dist/gui/assets/{chunk-5VM5RSS4-Cxz0034S.js → chunk-5VM5RSS4-CgCexDnG.js} +1 -1
- package/dist/gui/assets/{chunk-EX3LRPZG-KRbOxYPK.js → chunk-EX3LRPZG-DOWG8EpG.js} +1 -1
- package/dist/gui/assets/{chunk-JWPE2WC7-Xrz2UilG.js → chunk-JWPE2WC7-BMlmWtUB.js} +1 -1
- package/dist/gui/assets/{chunk-MOJQB5TN-BNaU7zJh.js → chunk-MOJQB5TN-DJUqDD4B.js} +1 -1
- package/dist/gui/assets/{chunk-RYQCIY6F-DWByW4ue.js → chunk-RYQCIY6F-BFi9jUjp.js} +1 -1
- package/dist/gui/assets/{chunk-V7JOEXUC-wqPxL9c_.js → chunk-V7JOEXUC-ByXHNLJe.js} +1 -1
- package/dist/gui/assets/{chunk-VR4S4FIN-CpNkwfNF.js → chunk-VR4S4FIN-B_D6T911.js} +1 -1
- package/dist/gui/assets/{chunk-XXDRQBXY-DfzP0kY8.js → chunk-XXDRQBXY-DjP4EgJp.js} +1 -1
- package/dist/gui/assets/classDiagram-OUVF2IWQ-BR-pwHie.js +1 -0
- package/dist/gui/assets/classDiagram-v2-EOCWNBFH-BR-pwHie.js +1 -0
- package/dist/gui/assets/{cose-bilkent-JH36ORCC-DRxVn8EA.js → cose-bilkent-JH36ORCC-CjD0qEvM.js} +1 -1
- package/dist/gui/assets/{cynefin-VYW2F7L2-Dgo2oRfE.js → cynefin-VYW2F7L2-C97W66bH.js} +1 -1
- package/dist/gui/assets/{cynefinDiagram-TSTJHNR4-DkOfGZKA.js → cynefinDiagram-TSTJHNR4-D4Wahgq2.js} +1 -1
- package/dist/gui/assets/{dagre-VKFMJZFB-B9lbU1Y5.js → dagre-VKFMJZFB-0TWp1RT0.js} +1 -1
- package/dist/gui/assets/{diagram-FQU43EPY-L4aTZZob.js → diagram-FQU43EPY-BV9a66lb.js} +1 -1
- package/dist/gui/assets/{diagram-G47NLZAW-C7EjvTdr.js → diagram-G47NLZAW-DKzlyZEe.js} +1 -1
- package/dist/gui/assets/{diagram-NH7WQ7WH-B3oafVE9.js → diagram-NH7WQ7WH-BQhml9wE.js} +1 -1
- package/dist/gui/assets/{diagram-OA4YK3LP-B5Xr4fVb.js → diagram-OA4YK3LP-DCzpOYMX.js} +1 -1
- package/dist/gui/assets/{diagram-WEI45ONY-CizYf4-d.js → diagram-WEI45ONY-DXr3HM2C.js} +1 -1
- package/dist/gui/assets/{ebnfDiagram-CCIWWBDH-Cdp4Ck2s.js → ebnfDiagram-CCIWWBDH-Ce9bZRby.js} +1 -1
- package/dist/gui/assets/{erDiagram-Q63AITRT-D_HQYmPu.js → erDiagram-Q63AITRT-QMnEo8uw.js} +1 -1
- package/dist/gui/assets/{flowDiagram-23GEKE2U-DOp2pyM-.js → flowDiagram-23GEKE2U-DGTJo1bv.js} +1 -1
- package/dist/gui/assets/{ganttDiagram-NO4QXBWP-jeuE6-Kq.js → ganttDiagram-NO4QXBWP-C4Z6wul5.js} +1 -1
- package/dist/gui/assets/{gitGraphDiagram-IHSO6WYX-BmHbinZO.js → gitGraphDiagram-IHSO6WYX-D3CUhb8K.js} +1 -1
- package/dist/gui/assets/index-BCBAWuCy.css +1 -0
- package/dist/gui/assets/index-DQGimWjn.js +36 -0
- package/dist/gui/assets/{infoDiagram-FWYZ7A6U-C1JAC0zw.js → infoDiagram-FWYZ7A6U-DrEI_bIp.js} +1 -1
- package/dist/gui/assets/{ishikawaDiagram-FXEZZL3T-C7gxtQ09.js → ishikawaDiagram-FXEZZL3T-SLiUSwvC.js} +1 -1
- package/dist/gui/assets/{journeyDiagram-5HDEW3XC-Bm09XlrS.js → journeyDiagram-5HDEW3XC-DOa-T3HP.js} +1 -1
- package/dist/gui/assets/{kanban-definition-HUTT4EX6-w-2VnSoA.js → kanban-definition-HUTT4EX6-CasK-nvo.js} +1 -1
- package/dist/gui/assets/{linear-DuGFha_A.js → linear-C0pax1ZS.js} +1 -1
- package/dist/gui/assets/{mermaid.core-DDfmGYIx.js → mermaid.core-DIdNUSOF.js} +4 -4
- package/dist/gui/assets/{mindmap-definition-LN4V7U3C-DSWPnA6W.js → mindmap-definition-LN4V7U3C-a_I5uzJ4.js} +1 -1
- package/dist/gui/assets/{pegDiagram-2B236MQR-Css8B66d.js → pegDiagram-2B236MQR-ChhsNm3o.js} +1 -1
- package/dist/gui/assets/{pieDiagram-ENE6RG2P-CSyx5YUR.js → pieDiagram-ENE6RG2P-CZZoIf-Y.js} +1 -1
- package/dist/gui/assets/{quadrantDiagram-ABIIQ3AL-Dd0BHS30.js → quadrantDiagram-ABIIQ3AL-DVXyLGdq.js} +1 -1
- package/dist/gui/assets/{railroadDiagram-RFXS5EU6-6tQYC8Kx.js → railroadDiagram-RFXS5EU6-BSfkVeLk.js} +1 -1
- package/dist/gui/assets/{requirementDiagram-TGXJPOKE-CIgyhQt1.js → requirementDiagram-TGXJPOKE-BBsnKguR.js} +1 -1
- package/dist/gui/assets/{sankeyDiagram-HTMAVEWB-CV8IW8dn.js → sankeyDiagram-HTMAVEWB-CpY2Xf40.js} +1 -1
- package/dist/gui/assets/{sequenceDiagram-DBY2YBRQ-XrPzK96U.js → sequenceDiagram-DBY2YBRQ-C88OWtrK.js} +1 -1
- package/dist/gui/assets/{sizeCapture-X5ZJPWSS-B0RuqhJy.js → sizeCapture-X5ZJPWSS-55d84kzI.js} +1 -1
- package/dist/gui/assets/{stateDiagram-2N3HPSRC-BbX5u2al.js → stateDiagram-2N3HPSRC-DEpNqk6b.js} +1 -1
- package/dist/gui/assets/stateDiagram-v2-6OUMAXLB-DgpJquA8.js +1 -0
- package/dist/gui/assets/{swimlanes-5IMT3BWC-eN6NeRxH.js → swimlanes-5IMT3BWC-BbZvxxWQ.js} +2 -2
- package/dist/gui/assets/swimlanesDiagram-G3AALYLV-1VFHiN5k.js +8 -0
- package/dist/gui/assets/{timeline-definition-FHXFAJF6-Cwx4rfjD.js → timeline-definition-FHXFAJF6-DXJKmih1.js} +1 -1
- package/dist/gui/assets/{vennDiagram-L72KCM5P-BfVuNGYO.js → vennDiagram-L72KCM5P-Dsx51jcc.js} +1 -1
- package/dist/gui/assets/{wardleyDiagram-EHGQE667-tTfKUfA2.js → wardleyDiagram-EHGQE667-DFchg4su.js} +1 -1
- package/dist/gui/assets/{xychartDiagram-FW5EYKEG-BMDTawgV.js → xychartDiagram-FW5EYKEG-9A_QhEe5.js} +1 -1
- package/dist/gui/index.html +2 -2
- package/package.json +1 -3
- package/dist/gui/assets/channel-3Bz_lxm-.js +0 -1
- package/dist/gui/assets/classDiagram-OUVF2IWQ-DX15YBOn.js +0 -1
- package/dist/gui/assets/classDiagram-v2-EOCWNBFH-DX15YBOn.js +0 -1
- package/dist/gui/assets/index-Ck5Q41lR.js +0 -36
- package/dist/gui/assets/index-DO4me3fw.css +0 -1
- package/dist/gui/assets/stateDiagram-v2-6OUMAXLB-DPSUOLxs.js +0 -1
- 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,
|
|
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
|
|
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
|
-
|
|
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(
|
|
55
|
-
if (
|
|
56
|
-
throw new Error(`Unknown agent: ${
|
|
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`
|
|
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
|
|
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
|
-
|
|
204
|
+
files2.push({ relPath, content });
|
|
177
205
|
}
|
|
178
|
-
return
|
|
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
|
|
189
|
-
if (!
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
248
|
+
files2.push({ relPath, content });
|
|
221
249
|
}
|
|
222
|
-
return fingerprintFiles(
|
|
250
|
+
return fingerprintFiles(files2);
|
|
223
251
|
}
|
|
224
|
-
function fingerprintFiles(
|
|
252
|
+
function fingerprintFiles(files2) {
|
|
225
253
|
const hash = createHash("sha256");
|
|
226
|
-
for (const f of [...
|
|
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
|
|
486
|
-
const stats = await stat(join(dir,
|
|
487
|
-
const ext = extname(
|
|
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:
|
|
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(
|
|
559
|
-
const lower =
|
|
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(
|
|
569
|
-
const ext = extname(
|
|
570
|
-
return ext ?
|
|
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(
|
|
578
|
-
if (!existing.includes(
|
|
579
|
-
const ext = extname(
|
|
580
|
-
const base = stripExt(
|
|
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(
|
|
588
|
-
if (!
|
|
589
|
-
if (
|
|
590
|
-
if (
|
|
591
|
-
if (
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
|
816
|
-
|
|
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`):使用 ``",
|
|
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
|
|
844
|
-
const
|
|
845
|
-
|
|
636
|
+
async function loadGlobalConfig(filePath) {
|
|
637
|
+
const fp = filePath ?? resolveGlobalConfigPath();
|
|
638
|
+
if (!existsSync(fp)) return defaultGlobalConfig();
|
|
639
|
+
let raw;
|
|
846
640
|
try {
|
|
847
|
-
|
|
641
|
+
raw = await readFile(fp, "utf8");
|
|
848
642
|
} catch {
|
|
849
|
-
return
|
|
643
|
+
return defaultGlobalConfig();
|
|
850
644
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
|
682
|
+
return {
|
|
683
|
+
version: CURRENT_VERSION$1,
|
|
684
|
+
projects,
|
|
685
|
+
agent: normalizeAgent$1(obj.agent),
|
|
686
|
+
notifications: normalizeNotifications(obj.notifications)
|
|
687
|
+
};
|
|
865
688
|
}
|
|
866
|
-
|
|
867
|
-
return
|
|
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
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
-
|
|
905
|
-
if (
|
|
906
|
-
|
|
872
|
+
async rotate() {
|
|
873
|
+
if (this.maxArchives <= 0) {
|
|
874
|
+
await writeFile(this.filePath, "", "utf8");
|
|
875
|
+
this.currentSize = 0;
|
|
876
|
+
return;
|
|
907
877
|
}
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
884
|
+
await rename(this.filePath, this.archivePath(1));
|
|
885
|
+
this.currentSize = 0;
|
|
912
886
|
}
|
|
913
|
-
return out;
|
|
914
887
|
}
|
|
915
|
-
function
|
|
916
|
-
|
|
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
|
|
923
|
-
if (!
|
|
924
|
-
const
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
931
|
-
|
|
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
|
-
|
|
934
|
-
|
|
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
|
-
|
|
952
|
-
|
|
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
|
-
|
|
971
|
-
return
|
|
917
|
+
get dir() {
|
|
918
|
+
return this.core.options.dir;
|
|
972
919
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
|
|
983
|
-
|
|
925
|
+
debug(msg, meta) {
|
|
926
|
+
this.emit("debug", msg, meta);
|
|
984
927
|
}
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
992
|
-
|
|
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
|
-
|
|
997
|
-
|
|
998
|
-
out.autoRun = obj.autoRun;
|
|
934
|
+
error(msg, meta) {
|
|
935
|
+
this.emit("error", msg, meta);
|
|
999
936
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
-
|
|
1026
|
-
const
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
|
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/
|
|
989
|
+
app.get("/projects/:projectId/specs", async (c) => {
|
|
1052
990
|
const p = await need(c);
|
|
1053
991
|
if (p instanceof Response) return p;
|
|
1054
|
-
|
|
992
|
+
const items = await p.store.list();
|
|
993
|
+
return c.json(items);
|
|
1055
994
|
});
|
|
1056
|
-
app.post("/projects/:projectId/
|
|
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
|
|
1068
|
-
return c.json(
|
|
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 },
|
|
1020
|
+
return c.json({ error: err.message }, 400);
|
|
1071
1021
|
}
|
|
1072
1022
|
});
|
|
1073
|
-
app.get("/projects/:projectId/specs/:id
|
|
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
|
-
|
|
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
|
-
|
|
1081
|
-
|
|
1082
|
-
return c.json({
|
|
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 },
|
|
1090
|
+
return c.json({ error: err.message }, 404);
|
|
1085
1091
|
}
|
|
1086
1092
|
});
|
|
1087
|
-
app.
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
|
1106
|
-
if (
|
|
1107
|
-
|
|
1108
|
-
if (
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
-
|
|
1119
|
-
|
|
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/
|
|
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
|
|
1125
|
-
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
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`):使用 ``",
|
|
1227
|
+
" - PDF(`.pdf`) / 文本(`.txt` / `.md` / `.markdown`):使用 `[<文件名>](attachments/<文件名>)`",
|
|
1228
|
+
"- 迁移失败(如 draft 目录已被清理、权限不足)时,**不要静默丢弃**:在 `## 待确认项` 章节追加一条记录说明问题,并退出本轮等待用户介入。"
|
|
1229
|
+
);
|
|
1179
1230
|
}
|
|
1180
|
-
return
|
|
1231
|
+
return lines.join("\n");
|
|
1181
1232
|
}
|
|
1182
|
-
async function
|
|
1233
|
+
async function bindDraftSessionToCreatedSpec(project, beforeIds, sessionId) {
|
|
1183
1234
|
try {
|
|
1184
|
-
const
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1199
|
-
|
|
1262
|
+
function formatSpecSessionTitle(specId, summary) {
|
|
1263
|
+
const s = summary.trim();
|
|
1264
|
+
return s ? `${specId} · ${s}` : specId;
|
|
1200
1265
|
}
|
|
1201
|
-
async function
|
|
1266
|
+
async function readDebugMdStatus(specDir) {
|
|
1267
|
+
const file = join(specDir, "debug.md");
|
|
1268
|
+
let text;
|
|
1202
1269
|
try {
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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
|
-
|
|
1219
|
-
const
|
|
1220
|
-
|
|
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
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
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
|
-
|
|
1281
|
-
if (!
|
|
1282
|
-
|
|
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
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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
|
-
|
|
1290
|
-
|
|
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
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
1310
|
-
|
|
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})`;
|
|
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.
|
|
1541
|
+
app.get("/projects/:projectId/sessions", async (c) => {
|
|
1320
1542
|
const p = await need(c);
|
|
1321
1543
|
if (p instanceof Response) return p;
|
|
1322
|
-
|
|
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/
|
|
1546
|
+
app.post("/projects/:projectId/sessions", async (c) => {
|
|
1338
1547
|
const p = await need(c);
|
|
1339
1548
|
if (p instanceof Response) return p;
|
|
1340
|
-
|
|
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
|
-
|
|
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
|
|
1385
|
-
return c.json(
|
|
1386
|
-
} catch {
|
|
1387
|
-
return c.json({
|
|
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/
|
|
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
|
|
1398
|
-
return c.json({
|
|
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
|
-
|
|
1401
|
-
throw err;
|
|
1574
|
+
return c.json({ error: err.message }, 500);
|
|
1402
1575
|
}
|
|
1403
1576
|
});
|
|
1404
|
-
app.
|
|
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
|
-
|
|
1423
|
-
return c.json(result);
|
|
1581
|
+
return c.json(await p.sessions.getMessages(c.req.param("sid")));
|
|
1424
1582
|
} catch (err) {
|
|
1425
|
-
|
|
1426
|
-
throw err;
|
|
1583
|
+
return c.json({ error: err.message }, 500);
|
|
1427
1584
|
}
|
|
1428
1585
|
});
|
|
1429
|
-
app.post("/projects/:projectId/
|
|
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
|
-
|
|
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
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
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/
|
|
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
|
|
1456
|
-
|
|
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
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
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
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
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 (
|
|
1520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
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
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
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
|
|
1577
|
-
|
|
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
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
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
|
-
|
|
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
|
|
1644
|
-
const
|
|
1645
|
-
|
|
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
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
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
|
-
|
|
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
|
|
1682
|
-
const
|
|
1683
|
-
|
|
1684
|
-
|
|
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
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
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
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
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
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
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
|
-
|
|
1710
|
-
|
|
1781
|
+
if (untracked.length) {
|
|
1782
|
+
await runGit(cwd, ["clean", "-fd", "--", ...untracked]);
|
|
1711
1783
|
}
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
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
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
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
|
-
|
|
1740
|
-
this.currentSize = st.size;
|
|
1952
|
+
body = await c.req.json();
|
|
1741
1953
|
} catch {
|
|
1742
|
-
|
|
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
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
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
|
-
|
|
1759
|
-
|
|
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
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
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
|
|
2447
|
+
const name2 = typeof obj.name === "string" ? obj.name.trim() : "";
|
|
2304
2448
|
const cli = typeof obj.cli === "string" ? obj.cli.trim() : "";
|
|
2305
|
-
if (!id || !
|
|
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
|
|
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
|
|
2574
|
-
if (typeof
|
|
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,
|
|
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$
|
|
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$
|
|
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(
|
|
3036
|
-
const trimmedName =
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
|
3692
|
+
const name2 = typeof obj.name === "string" ? obj.name.trim() : "";
|
|
3549
3693
|
const cli = typeof obj.cli === "string" ? obj.cli.trim() : "";
|
|
3550
|
-
if (!
|
|
3551
|
-
if (
|
|
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
|
|
3995
|
+
const files2 = unmerged.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
3821
3996
|
const commitsByFile = {};
|
|
3822
|
-
for (const file of
|
|
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
|
|
3896
|
-
if (!
|
|
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
|
|
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(
|
|
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
|
|
4229
|
-
if (!
|
|
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 =
|
|
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(` - 描述:${
|
|
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(
|
|
4499
|
+
async allocateId(type2, summary) {
|
|
4319
4500
|
const slug = kebab(summary);
|
|
4320
|
-
const base = `${this.todayCompact()}.${
|
|
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
|
|
5104
|
+
const files2 = await this.walkRollouts();
|
|
4924
5105
|
const titleIndex = await this.readSessionIndex();
|
|
4925
5106
|
const out = [];
|
|
4926
|
-
for (const file of
|
|
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
|
|
5051
|
-
const match =
|
|
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
|
|
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
|
|
5563
|
-
return
|
|
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
|
|
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
|
|
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
|
|
5905
|
-
for (const info of ifaces[
|
|
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,
|
|
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 !==
|
|
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 = (
|
|
6853
|
-
if (
|
|
6854
|
-
Object.defineProperty(globalThis,
|
|
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
|
|
7290
|
-
if (
|
|
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);
|