@yorz/cli 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/cli/index.js +846 -82
  2. package/dist/gui/assets/{abnfDiagram-VRR7QNED-Bfi5VmJM.js → abnfDiagram-VRR7QNED-D1kFOejy.js} +1 -1
  3. package/dist/gui/assets/{arc-DW35HiMx.js → arc--bTdICho.js} +1 -1
  4. package/dist/gui/assets/{architectureDiagram-ZJ3FMSHR-D6ol2HjT.js → architectureDiagram-ZJ3FMSHR-C3DMgcAW.js} +1 -1
  5. package/dist/gui/assets/{blockDiagram-677ZJIJ3-DimsChzs.js → blockDiagram-677ZJIJ3-BCtvFOy7.js} +1 -1
  6. package/dist/gui/assets/{c4Diagram-LMCZKHZV-aQtOPEhO.js → c4Diagram-LMCZKHZV-CDw5fv3L.js} +1 -1
  7. package/dist/gui/assets/channel-Zv7U9soI.js +1 -0
  8. package/dist/gui/assets/{chunk-2Q5K7J3B-Bg7XMADz.js → chunk-2Q5K7J3B-DbJTSeCo.js} +1 -1
  9. package/dist/gui/assets/{chunk-32BRIVSS-CJvj41Qg.js → chunk-32BRIVSS-BcF_P8AP.js} +1 -1
  10. package/dist/gui/assets/{chunk-5VM5RSS4-B6iQkE6V.js → chunk-5VM5RSS4-CYp4dWLx.js} +1 -1
  11. package/dist/gui/assets/{chunk-EX3LRPZG-Bime3fML.js → chunk-EX3LRPZG-BDzejC8h.js} +1 -1
  12. package/dist/gui/assets/{chunk-JWPE2WC7-B2d6pOPL.js → chunk-JWPE2WC7-Fnenn1Qc.js} +1 -1
  13. package/dist/gui/assets/{chunk-MOJQB5TN-C2BtAAqD.js → chunk-MOJQB5TN-Cx7nt_sO.js} +1 -1
  14. package/dist/gui/assets/{chunk-RYQCIY6F-DvOYjxsw.js → chunk-RYQCIY6F-MQPZLynl.js} +1 -1
  15. package/dist/gui/assets/{chunk-V7JOEXUC-HRF7SSaJ.js → chunk-V7JOEXUC-F5RsZN0n.js} +1 -1
  16. package/dist/gui/assets/{chunk-VR4S4FIN-oMVpMTS0.js → chunk-VR4S4FIN-CoOdAASP.js} +1 -1
  17. package/dist/gui/assets/{chunk-XXDRQBXY-Gx6kiEHQ.js → chunk-XXDRQBXY-Dp3kfiet.js} +1 -1
  18. package/dist/gui/assets/classDiagram-OUVF2IWQ-DRHl_SaL.js +1 -0
  19. package/dist/gui/assets/classDiagram-v2-EOCWNBFH-DRHl_SaL.js +1 -0
  20. package/dist/gui/assets/{cose-bilkent-JH36ORCC-MoHCpl37.js → cose-bilkent-JH36ORCC-PfqHyC7G.js} +1 -1
  21. package/dist/gui/assets/{cynefin-VYW2F7L2-2tnFSmNB.js → cynefin-VYW2F7L2-DerSwhBp.js} +1 -1
  22. package/dist/gui/assets/{cynefinDiagram-TSTJHNR4-Cp7uDUjl.js → cynefinDiagram-TSTJHNR4-BwakZjCU.js} +1 -1
  23. package/dist/gui/assets/{dagre-VKFMJZFB-CTNuVp99.js → dagre-VKFMJZFB-Bqgor74s.js} +1 -1
  24. package/dist/gui/assets/{diagram-FQU43EPY-Z9nTO-2q.js → diagram-FQU43EPY-DgyWxpcU.js} +1 -1
  25. package/dist/gui/assets/{diagram-G47NLZAW-D1H3xA3x.js → diagram-G47NLZAW-DwvD_Up0.js} +1 -1
  26. package/dist/gui/assets/{diagram-NH7WQ7WH-BGpM31nf.js → diagram-NH7WQ7WH-DSimddOu.js} +1 -1
  27. package/dist/gui/assets/{diagram-OA4YK3LP-Bhn6yNbV.js → diagram-OA4YK3LP-CrBUd7XP.js} +1 -1
  28. package/dist/gui/assets/{diagram-WEI45ONY-BDJ6eaig.js → diagram-WEI45ONY-BWgcRZqn.js} +1 -1
  29. package/dist/gui/assets/{ebnfDiagram-CCIWWBDH-C8UHOTF4.js → ebnfDiagram-CCIWWBDH-g9dEaUVe.js} +1 -1
  30. package/dist/gui/assets/{erDiagram-Q63AITRT-BpoLARsO.js → erDiagram-Q63AITRT-DhYVc7YD.js} +1 -1
  31. package/dist/gui/assets/{flowDiagram-23GEKE2U-DSODAemd.js → flowDiagram-23GEKE2U-COeJThES.js} +1 -1
  32. package/dist/gui/assets/{ganttDiagram-NO4QXBWP-Bv5JA_dU.js → ganttDiagram-NO4QXBWP-DJxQzqsI.js} +1 -1
  33. package/dist/gui/assets/{gitGraphDiagram-IHSO6WYX-CW7ObzYy.js → gitGraphDiagram-IHSO6WYX-CC_xIkVG.js} +1 -1
  34. package/dist/gui/assets/{index-Cft6LQWD.css → index-BzxGbxnX.css} +1 -1
  35. package/dist/gui/assets/index-tquwDQwL.js +39 -0
  36. package/dist/gui/assets/{infoDiagram-FWYZ7A6U-uTM_03X9.js → infoDiagram-FWYZ7A6U-C6FK6dRz.js} +1 -1
  37. package/dist/gui/assets/{ishikawaDiagram-FXEZZL3T-BlbCXOf8.js → ishikawaDiagram-FXEZZL3T-D7b3GjN0.js} +1 -1
  38. package/dist/gui/assets/{journeyDiagram-5HDEW3XC-BwtfR_HU.js → journeyDiagram-5HDEW3XC-Ct0_MZXS.js} +1 -1
  39. package/dist/gui/assets/{kanban-definition-HUTT4EX6-BXwvYv3u.js → kanban-definition-HUTT4EX6-M4nszR7h.js} +1 -1
  40. package/dist/gui/assets/{linear-C5c8PyN7.js → linear-CGUJayt8.js} +1 -1
  41. package/dist/gui/assets/{mermaid.core-Dbw2b69i.js → mermaid.core-DpiXCPQI.js} +4 -4
  42. package/dist/gui/assets/{mindmap-definition-LN4V7U3C-9P8fXZBY.js → mindmap-definition-LN4V7U3C-CBbqzgM2.js} +1 -1
  43. package/dist/gui/assets/{pegDiagram-2B236MQR-81kUPbkr.js → pegDiagram-2B236MQR-BW9OSYgR.js} +1 -1
  44. package/dist/gui/assets/{pieDiagram-ENE6RG2P-BI7iwgtu.js → pieDiagram-ENE6RG2P-CT3MdE-x.js} +1 -1
  45. package/dist/gui/assets/{quadrantDiagram-ABIIQ3AL-CSxA_Epi.js → quadrantDiagram-ABIIQ3AL-BAqDSslO.js} +1 -1
  46. package/dist/gui/assets/{railroadDiagram-RFXS5EU6-D4DnN2WS.js → railroadDiagram-RFXS5EU6-BjxuOun7.js} +1 -1
  47. package/dist/gui/assets/{requirementDiagram-TGXJPOKE-DjNONyxQ.js → requirementDiagram-TGXJPOKE-bqwQ3Xy2.js} +1 -1
  48. package/dist/gui/assets/{sankeyDiagram-HTMAVEWB-Di4S5cUR.js → sankeyDiagram-HTMAVEWB-D5UFHu3G.js} +1 -1
  49. package/dist/gui/assets/{sequenceDiagram-DBY2YBRQ-CLVwUzez.js → sequenceDiagram-DBY2YBRQ-BimXCn08.js} +1 -1
  50. package/dist/gui/assets/{sizeCapture-X5ZJPWSS-C5y0Ry_b.js → sizeCapture-X5ZJPWSS-3iod82ny.js} +1 -1
  51. package/dist/gui/assets/{stateDiagram-2N3HPSRC-IdHsn6JH.js → stateDiagram-2N3HPSRC-BCkRUWaU.js} +1 -1
  52. package/dist/gui/assets/stateDiagram-v2-6OUMAXLB-7yy6idF2.js +1 -0
  53. package/dist/gui/assets/{swimlanes-5IMT3BWC-bDCHEODt.js → swimlanes-5IMT3BWC-D7XMYBp1.js} +2 -2
  54. package/dist/gui/assets/swimlanesDiagram-G3AALYLV-DGa9rk3s.js +8 -0
  55. package/dist/gui/assets/{timeline-definition-FHXFAJF6-B6f6vq0E.js → timeline-definition-FHXFAJF6-D9U9I24L.js} +1 -1
  56. package/dist/gui/assets/{vennDiagram-L72KCM5P-AjYH8jaU.js → vennDiagram-L72KCM5P-DKs7jWNI.js} +1 -1
  57. package/dist/gui/assets/{wardleyDiagram-EHGQE667-NV3g14vB.js → wardleyDiagram-EHGQE667-CLvHSSW6.js} +1 -1
  58. package/dist/gui/assets/{xychartDiagram-FW5EYKEG-D4yDWaI8.js → xychartDiagram-FW5EYKEG-V0U7x5OK.js} +1 -1
  59. package/dist/gui/index.html +2 -2
  60. package/package.json +2 -1
  61. package/dist/gui/assets/channel-CYD2fo3z.js +0 -1
  62. package/dist/gui/assets/classDiagram-OUVF2IWQ-CFzbN5IF.js +0 -1
  63. package/dist/gui/assets/classDiagram-v2-EOCWNBFH-CFzbN5IF.js +0 -1
  64. package/dist/gui/assets/index-CAnHTFea.js +0 -38
  65. package/dist/gui/assets/stateDiagram-v2-6OUMAXLB-CJBs-brH.js +0 -1
  66. package/dist/gui/assets/swimlanesDiagram-G3AALYLV-BpY4RNvM.js +0 -8
package/dist/cli/index.js CHANGED
@@ -23,7 +23,7 @@ import { createOpencode } from "@opencode-ai/sdk";
23
23
  import { createInterface as createInterface$1 } from "node:readline/promises";
24
24
  import MarkdownIt from "markdown-it";
25
25
  const name = "@yorz/cli";
26
- const version = "0.7.1";
26
+ const version = "0.8.0";
27
27
  const type = "module";
28
28
  const description = "";
29
29
  const bin = { "yorz": "./dist/cli/index.js" };
@@ -35,7 +35,7 @@ const repository = { "type": "git", "url": "git+https://github.com/hughfenghen/Y
35
35
  const homepage = "https://github.com/hughfenghen/YorZ#readme";
36
36
  const publishConfig = { "access": "public" };
37
37
  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": "pnpm run build && playwright test", "format": 'prettier --write "src/**/*.{ts,tsx,md,css}"', "prepublishOnly": "pnpm run build" };
38
- 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 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", "parse-diff": "^0.12.0", "timeago.js": "^4.0.2", "trash": "^10.1.1" };
39
39
  const devDependencies = { "@fontsource/jetbrains-mono": "^5.3.0", "@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" };
40
40
  const pkg = {
41
41
  name,
@@ -1199,6 +1199,202 @@ function skillEntryPath(skillName, env = process.env) {
1199
1199
  function skillRef(skillName, env = process.env) {
1200
1200
  return `请先完整阅读并严格遵循 ${skillEntryPath(skillName, env)}(YorZ 内置 ${skillName} skill 规则)`;
1201
1201
  }
1202
+ const METRICS_DIR_NAME = "metrics";
1203
+ const TELEMETRY_FILE_NAME = "telemetry.jsonl";
1204
+ const PROJECT_META_FILE_NAME = "project.json";
1205
+ const DEFAULT_TELEMETRY_MAX_BYTES = 5 * 1024 * 1024;
1206
+ const DEFAULT_TELEMETRY_MAX_ARCHIVES = 2;
1207
+ function resolveMetricsDir(env = process.env) {
1208
+ return join(resolveGlobalConfigDir(env), METRICS_DIR_NAME);
1209
+ }
1210
+ function resolveProjectMetricsDir(projectRoot, env = process.env) {
1211
+ return join(resolveMetricsDir(env), generateProjectId(resolve(projectRoot)));
1212
+ }
1213
+ function findProjectRoot(startDir) {
1214
+ let dir = resolve(startDir);
1215
+ for (; ; ) {
1216
+ if (existsSync(join(dir, ".yorz"))) return dir;
1217
+ const parent = dirname(dir);
1218
+ if (parent === dir) return null;
1219
+ dir = parent;
1220
+ }
1221
+ }
1222
+ function normalizeUsage(kind, raw) {
1223
+ if (!raw || typeof raw !== "object") return void 0;
1224
+ const obj = raw;
1225
+ const snapshot = kind === "codex" ? fromCodex(obj) : kind === "opencode" ? fromOpenCode(obj) : fromClaude(obj);
1226
+ return isEmpty(snapshot) ? void 0 : snapshot;
1227
+ }
1228
+ function fromClaude(u) {
1229
+ return compact({
1230
+ inputTokens: num$2(u.input_tokens),
1231
+ outputTokens: num$2(u.output_tokens),
1232
+ cacheReadTokens: num$2(u.cache_read_input_tokens),
1233
+ cacheCreateTokens: num$2(u.cache_creation_input_tokens)
1234
+ });
1235
+ }
1236
+ function fromCodex(u) {
1237
+ const total = num$2(u.input_tokens);
1238
+ const cached = num$2(u.cached_input_tokens);
1239
+ const uncached = total !== void 0 && cached !== void 0 ? Math.max(total - cached, 0) : total;
1240
+ return compact({
1241
+ inputTokens: uncached,
1242
+ cacheReadTokens: cached,
1243
+ outputTokens: num$2(u.output_tokens),
1244
+ reasoningTokens: num$2(u.reasoning_output_tokens)
1245
+ });
1246
+ }
1247
+ function fromOpenCode(u) {
1248
+ const cache = u.cache && typeof u.cache === "object" ? u.cache : {};
1249
+ return compact({
1250
+ inputTokens: num$2(u.input),
1251
+ outputTokens: num$2(u.output),
1252
+ reasoningTokens: num$2(u.reasoning),
1253
+ cacheReadTokens: num$2(cache.read),
1254
+ cacheCreateTokens: num$2(cache.write)
1255
+ });
1256
+ }
1257
+ function num$2(value) {
1258
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1259
+ }
1260
+ function compact(snapshot) {
1261
+ const out = {};
1262
+ for (const [key, value] of Object.entries(snapshot)) {
1263
+ if (value !== void 0) out[key] = value;
1264
+ }
1265
+ return out;
1266
+ }
1267
+ function isEmpty(snapshot) {
1268
+ return Object.keys(snapshot).length === 0;
1269
+ }
1270
+ const TELEMETRY_SCHEMA_VERSION = 1;
1271
+ function isTelemetryEnabled(env = process.env) {
1272
+ const raw = env.YORZ_TELEMETRY?.trim().toLowerCase();
1273
+ if (!raw) return true;
1274
+ return !["off", "0", "false", "no", "disabled"].includes(raw);
1275
+ }
1276
+ class TelemetryRecorder {
1277
+ constructor(projectRoot, projectId, dir, enabled) {
1278
+ this.projectRoot = projectRoot;
1279
+ this.projectId = projectId;
1280
+ this.dir = dir;
1281
+ this.enabled = enabled;
1282
+ this.sink = enabled ? new RotatingFileSink(
1283
+ dir,
1284
+ TELEMETRY_FILE_NAME,
1285
+ DEFAULT_TELEMETRY_MAX_BYTES,
1286
+ DEFAULT_TELEMETRY_MAX_ARCHIVES
1287
+ ) : null;
1288
+ }
1289
+ sink;
1290
+ metaWritten = false;
1291
+ get filePath() {
1292
+ return join(this.dir, TELEMETRY_FILE_NAME);
1293
+ }
1294
+ /** Queue one event. Returns immediately; never throws. */
1295
+ record(event, payload = {}) {
1296
+ if (!this.sink) return;
1297
+ let line;
1298
+ try {
1299
+ const envelope = {
1300
+ v: TELEMETRY_SCHEMA_VERSION,
1301
+ ts: nowStamp(),
1302
+ event,
1303
+ projectId: this.projectId,
1304
+ ...payload
1305
+ };
1306
+ line = `${JSON.stringify(envelope)}
1307
+ `;
1308
+ } catch {
1309
+ return;
1310
+ }
1311
+ this.sink.write(line);
1312
+ this.ensureMeta();
1313
+ }
1314
+ /** Resolve once every queued line has hit the disk (tests / shutdown). */
1315
+ async flush() {
1316
+ await this.sink?.flush();
1317
+ }
1318
+ /** Write the id → absolute path sidecar once per process. Best-effort. */
1319
+ ensureMeta() {
1320
+ if (this.metaWritten || !this.sink) return;
1321
+ this.metaWritten = true;
1322
+ const meta = {
1323
+ id: this.projectId,
1324
+ path: this.projectRoot,
1325
+ firstSeenAt: nowStamp()
1326
+ };
1327
+ void (async () => {
1328
+ try {
1329
+ await mkdir(this.dir, { recursive: true });
1330
+ await writeFile(join(this.dir, PROJECT_META_FILE_NAME), `${JSON.stringify(meta, null, 2)}
1331
+ `);
1332
+ } catch {
1333
+ }
1334
+ })();
1335
+ }
1336
+ }
1337
+ const recorders = /* @__PURE__ */ new Map();
1338
+ function getTelemetry(projectRoot, env = process.env) {
1339
+ const root = resolve(projectRoot);
1340
+ const cached = recorders.get(root);
1341
+ if (cached) return cached;
1342
+ const recorder = new TelemetryRecorder(
1343
+ root,
1344
+ generateProjectId(root),
1345
+ resolveProjectMetricsDir(root, env),
1346
+ isTelemetryEnabled(env)
1347
+ );
1348
+ recorders.set(root, recorder);
1349
+ return recorder;
1350
+ }
1351
+ function nowStamp(date = /* @__PURE__ */ new Date()) {
1352
+ const pad = (n) => String(n).padStart(2, "0");
1353
+ const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
1354
+ return `${ymd} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
1355
+ }
1356
+ const TASK_LINE = /^- \[( |x|X)\]/gm;
1357
+ function snapshotSpec(detail) {
1358
+ if (!detail) return null;
1359
+ let tasksTotal = 0;
1360
+ let tasksDone = 0;
1361
+ for (const match of detail.body.matchAll(TASK_LINE)) {
1362
+ tasksTotal += 1;
1363
+ if (match[1] !== " ") tasksDone += 1;
1364
+ }
1365
+ return {
1366
+ stage: detail.frontmatter.stage,
1367
+ tasksTotal,
1368
+ tasksDone,
1369
+ specBytes: Buffer.byteLength(detail.body, "utf8")
1370
+ };
1371
+ }
1372
+ function trackSpecStage(opts) {
1373
+ const { projectRoot, store, specId, handle, before, trigger } = opts;
1374
+ const beforeSnapshot = snapshotSpec(before);
1375
+ handle.onDone(() => {
1376
+ void (async () => {
1377
+ let afterSnapshot = null;
1378
+ try {
1379
+ afterSnapshot = snapshotSpec(await store.read(specId));
1380
+ } catch {
1381
+ }
1382
+ const payload = {
1383
+ specId,
1384
+ trigger,
1385
+ traceId: handle.runId,
1386
+ sessionId: handle.sessionId,
1387
+ stageBefore: beforeSnapshot?.stage,
1388
+ stageAfter: afterSnapshot?.stage,
1389
+ tasksTotal: afterSnapshot?.tasksTotal,
1390
+ tasksDone: afterSnapshot?.tasksDone,
1391
+ tasksDoneBefore: beforeSnapshot?.tasksDone,
1392
+ specBytes: afterSnapshot?.specBytes
1393
+ };
1394
+ getTelemetry(projectRoot).record("spec.stage", payload);
1395
+ })();
1396
+ });
1397
+ }
1202
1398
  function createSpecsRoutes(resolveProject) {
1203
1399
  const app = new Hono();
1204
1400
  const need = async (c) => {
@@ -1229,7 +1425,7 @@ function createSpecsRoutes(resolveProject) {
1229
1425
  const beforeIds = new Set((await p.store.list()).map((s) => s.id));
1230
1426
  const prompt = buildDraftPrompt(input.type, input.requirement, input.draftId);
1231
1427
  const { sessionId } = await p.sessions.createSession();
1232
- const handle = await p.sessions.send(sessionId, prompt);
1428
+ const handle = await p.sessions.send(sessionId, prompt, void 0, { trigger: "new-spec" });
1233
1429
  handle.onDone((finalSessionId) => {
1234
1430
  void bindDraftSessionToCreatedSpec(p, beforeIds, finalSessionId);
1235
1431
  });
@@ -1365,7 +1561,18 @@ function createSpecsRoutes(resolveProject) {
1365
1561
  "resume",
1366
1562
  await buildDebugRuntimeContext(p)
1367
1563
  ) : `${skillRef("yorz-spec")},然后处理 spec:${p.specsDirRelative}/${specId}/spec.md`;
1368
- const handle = await p.sessions.send(sessionId, prompt);
1564
+ const handle = await p.sessions.send(sessionId, prompt, void 0, {
1565
+ trigger: "append",
1566
+ specId
1567
+ });
1568
+ trackSpecStage({
1569
+ projectRoot: p.path,
1570
+ store: p.store,
1571
+ specId,
1572
+ handle,
1573
+ before: detail,
1574
+ trigger: "append"
1575
+ });
1369
1576
  return c.json({ ok: true, runId: handle.runId, sessionId });
1370
1577
  }
1371
1578
  return c.json({ ok: true });
@@ -1379,7 +1586,18 @@ function createSpecsRoutes(resolveProject) {
1379
1586
  const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
1380
1587
  const debugActive = await readDebugMdStatus(join(p.specsDir, specId)) === "debugging";
1381
1588
  const prompt = debugActive ? buildDebugPrompt(p.specsDirRelative, specId, "resume", await buildDebugRuntimeContext(p)) : `${skillRef("yorz-spec")},然后处理 spec:${p.specsDirRelative}/${specId}/spec.md`;
1382
- const handle = await p.sessions.send(sessionId, prompt);
1589
+ const handle = await p.sessions.send(sessionId, prompt, void 0, {
1590
+ trigger: "run",
1591
+ specId
1592
+ });
1593
+ trackSpecStage({
1594
+ projectRoot: p.path,
1595
+ store: p.store,
1596
+ specId,
1597
+ handle,
1598
+ before: detail,
1599
+ trigger: "run"
1600
+ });
1383
1601
  return c.json({ runId: handle.runId, sessionId });
1384
1602
  });
1385
1603
  app.post("/projects/:projectId/specs/:id/explain", async (c) => {
@@ -1410,7 +1628,10 @@ ${text}
1410
1628
  """
1411
1629
  `;
1412
1630
  const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
1413
- const handle = await p.sessions.send(sessionId, prompt);
1631
+ const handle = await p.sessions.send(sessionId, prompt, void 0, {
1632
+ trigger: "explain",
1633
+ specId
1634
+ });
1414
1635
  return c.json({ runId: handle.runId, sessionId });
1415
1636
  });
1416
1637
  app.delete("/projects/:projectId/specs/:id", async (c) => {
@@ -2002,7 +2223,9 @@ function createSessionsRoutes(resolveProject) {
2002
2223
  }
2003
2224
  if (resolved.builtin === "yorz-debug") void cleanupExpiredChatDebugFiles(p.path).catch(() => {
2004
2225
  });
2005
- const handle = await p.sessions.send(c.req.param("sid"), finalPrompt, prompt);
2226
+ const handle = await p.sessions.send(c.req.param("sid"), finalPrompt, prompt, {
2227
+ trigger: "chat"
2228
+ });
2006
2229
  return c.json({ runId: handle.runId, sessionId: handle.sessionId }, 202);
2007
2230
  });
2008
2231
  app.post("/projects/:projectId/sessions/:sid/abort", async (c) => {
@@ -2082,16 +2305,27 @@ function assertSafeRelativePath(cwd, p) {
2082
2305
  }
2083
2306
  return p;
2084
2307
  }
2308
+ function recordGitOp(cwd, args, startedAt, ok, code) {
2309
+ getTelemetry(cwd).record("git.op", {
2310
+ op: args[0] ?? "unknown",
2311
+ ok,
2312
+ exitCode: code,
2313
+ durMs: Date.now() - startedAt
2314
+ });
2315
+ }
2085
2316
  async function runGit(cwd, args) {
2317
+ const startedAt = Date.now();
2086
2318
  try {
2087
2319
  const result = await execFileWithoutWindow("git", args, {
2088
2320
  cwd,
2089
2321
  encoding: "utf8",
2090
2322
  maxBuffer: 16 * 1024 * 1024
2091
2323
  });
2324
+ recordGitOp(cwd, args, startedAt, true, 0);
2092
2325
  return { stdout: result.stdout, stderr: result.stderr };
2093
2326
  } catch (err) {
2094
2327
  const e = err;
2328
+ recordGitOp(cwd, args, startedAt, false, typeof e.code === "number" ? e.code : void 0);
2095
2329
  if (e.code === "ENOENT") {
2096
2330
  throw new GitError("git_missing", "git not found on PATH");
2097
2331
  }
@@ -2102,19 +2336,23 @@ async function runGitChecked(cwd, args) {
2102
2336
  return runGit(cwd, args);
2103
2337
  }
2104
2338
  async function runGitRaw(cwd, args) {
2339
+ const startedAt = Date.now();
2105
2340
  try {
2106
2341
  const result = await execFileWithoutWindow("git", args, {
2107
2342
  cwd,
2108
2343
  encoding: "utf8",
2109
2344
  maxBuffer: 16 * 1024 * 1024
2110
2345
  });
2346
+ recordGitOp(cwd, args, startedAt, true, 0);
2111
2347
  return { stdout: result.stdout, stderr: result.stderr, code: 0 };
2112
2348
  } catch (err) {
2113
2349
  const e = err;
2114
2350
  if (e.code === "ENOENT") {
2351
+ recordGitOp(cwd, args, startedAt, false);
2115
2352
  throw new GitError("git_missing", "git not found on PATH");
2116
2353
  }
2117
2354
  const code = typeof e.code === "number" ? e.code : 1;
2355
+ recordGitOp(cwd, args, startedAt, false, code);
2118
2356
  return { stdout: e.stdout ?? "", stderr: e.stderr ?? e.message ?? "", code };
2119
2357
  }
2120
2358
  }
@@ -2198,6 +2436,114 @@ async function discard(cwd, opts) {
2198
2436
  await runGit(cwd, ["clean", "-fd", "--", r.path]);
2199
2437
  }
2200
2438
  }
2439
+ const MAX_PATCH_BYTES = 512 * 1024;
2440
+ const MAX_PATCH_LINES = 3e3;
2441
+ function truncatePatch(patch) {
2442
+ let text = patch;
2443
+ let truncated = false;
2444
+ if (Buffer.byteLength(text, "utf8") > MAX_PATCH_BYTES) {
2445
+ text = text.slice(0, MAX_PATCH_BYTES);
2446
+ truncated = true;
2447
+ }
2448
+ const lines = text.split("\n");
2449
+ if (lines.length > MAX_PATCH_LINES) {
2450
+ text = lines.slice(0, MAX_PATCH_LINES).join("\n");
2451
+ truncated = true;
2452
+ }
2453
+ return { patch: text, truncated };
2454
+ }
2455
+ async function untrackedDiff(cwd, path) {
2456
+ let buf;
2457
+ try {
2458
+ buf = await readFile(resolve(cwd, path));
2459
+ } catch {
2460
+ return { path, patch: "", binary: false, truncated: false };
2461
+ }
2462
+ if (buf.subarray(0, 8e3).includes(0)) {
2463
+ return { path, patch: "", binary: true, truncated: false };
2464
+ }
2465
+ const text = buf.toString("utf8");
2466
+ const lines = text.length === 0 ? [] : text.replace(/\n$/, "").split("\n");
2467
+ const body = lines.map((l) => `+${l}`).join("\n");
2468
+ const header = `--- /dev/null
2469
+ +++ b/${path}
2470
+ @@ -0,0 +1,${lines.length} @@
2471
+ `;
2472
+ const { patch, truncated } = truncatePatch(lines.length ? `${header}${body}` : header);
2473
+ return { path, patch, binary: false, truncated };
2474
+ }
2475
+ async function fileDiff(cwd, path) {
2476
+ assertSafeRelativePath(cwd, path);
2477
+ const change = (await listChanges(cwd)).find((c) => c.path === path);
2478
+ if (change?.status === "??") return untrackedDiff(cwd, path);
2479
+ const pathArgs = change?.renamedFrom ? [change.renamedFrom, path] : [path];
2480
+ const res = await runGitRaw(cwd, ["diff", "HEAD", "--", ...pathArgs]);
2481
+ if (res.code !== 0 && !res.stdout) {
2482
+ throw new GitError("git_failed", res.stderr.trim() || "git diff failed", res.stderr);
2483
+ }
2484
+ const raw = res.stdout;
2485
+ if (!raw.includes("@@") && /^Binary files .* differ$/m.test(raw)) {
2486
+ return { path, patch: "", binary: true, truncated: false };
2487
+ }
2488
+ const { patch, truncated } = truncatePatch(raw);
2489
+ return { path, patch, binary: false, truncated };
2490
+ }
2491
+ async function currentBranch(cwd) {
2492
+ const { stdout } = await runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
2493
+ const branch = stdout.trim();
2494
+ if (!branch || branch === "HEAD") {
2495
+ throw new GitError("detached_head", "cannot operate from a detached HEAD");
2496
+ }
2497
+ return branch;
2498
+ }
2499
+ async function listBranches(cwd) {
2500
+ const [current, branchesResult] = await Promise.all([
2501
+ currentBranch(cwd),
2502
+ runGit(cwd, ["branch", "--format=%(refname:short)"])
2503
+ ]);
2504
+ const branches = branchesResult.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
2505
+ return { current, branches };
2506
+ }
2507
+ async function checkoutBranch(cwd, branch) {
2508
+ const target = branch?.trim() ?? "";
2509
+ if (!target) throw new GitError("invalid_branch", "branch must not be empty");
2510
+ const state = await listBranches(cwd);
2511
+ if (!state.branches.includes(target)) {
2512
+ throw new GitError("invalid_branch", `unknown local branch: ${target}`);
2513
+ }
2514
+ if (state.current === target) return { current: state.current };
2515
+ const res = await runGitRaw(cwd, ["checkout", target]);
2516
+ if (res.code !== 0) {
2517
+ throw new GitError("checkout_failed", res.stderr.trim() || "git checkout failed", res.stderr);
2518
+ }
2519
+ return { current: await currentBranch(cwd) };
2520
+ }
2521
+ async function push(cwd) {
2522
+ const branch = await currentBranch(cwd);
2523
+ const upstream = await runGitRaw(cwd, [
2524
+ "rev-parse",
2525
+ "--abbrev-ref",
2526
+ "--symbolic-full-name",
2527
+ "@{u}"
2528
+ ]);
2529
+ const hasUpstream = upstream.code === 0 && upstream.stdout.trim().length > 0;
2530
+ const args = hasUpstream ? ["push"] : ["push", "--set-upstream", "origin", branch];
2531
+ const res = await runGitRaw(cwd, args);
2532
+ if (res.code !== 0) {
2533
+ throw new GitError("push_failed", res.stderr.trim() || "git push failed", res.stderr);
2534
+ }
2535
+ return { branch, createdUpstream: !hasUpstream };
2536
+ }
2537
+ async function pull(cwd) {
2538
+ const branch = await currentBranch(cwd);
2539
+ const before = (await runGit(cwd, ["rev-parse", "HEAD"])).stdout.trim();
2540
+ const res = await runGitRaw(cwd, ["pull", "--ff-only"]);
2541
+ if (res.code !== 0) {
2542
+ throw new GitError("pull_failed", res.stderr.trim() || "git pull failed", res.stderr);
2543
+ }
2544
+ const after = (await runGit(cwd, ["rev-parse", "HEAD"])).stdout.trim();
2545
+ return { branch, updated: before !== after };
2546
+ }
2201
2547
  async function stash(cwd, opts) {
2202
2548
  if (!Array.isArray(opts.paths) || opts.paths.length === 0) {
2203
2549
  throw new GitError("no_paths", "stash requires at least one path");
@@ -2238,7 +2584,18 @@ function createSpecReviewRoutes(resolveProject) {
2238
2584
  const specRel = `${p.specsDirRelative}/${specId}/spec.md`;
2239
2585
  const prompt = buildGitOpsPrompt(action, specId, specRel);
2240
2586
  const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
2241
- const handle = await p.sessions.send(sessionId, prompt);
2587
+ const handle = await p.sessions.send(sessionId, prompt, void 0, {
2588
+ trigger: "git-ops",
2589
+ specId
2590
+ });
2591
+ trackSpecStage({
2592
+ projectRoot: p.path,
2593
+ store: p.store,
2594
+ specId,
2595
+ handle,
2596
+ before: detail,
2597
+ trigger: "git-ops"
2598
+ });
2242
2599
  return c.json({ runId: handle.runId, sessionId });
2243
2600
  });
2244
2601
  app.get("/projects/:projectId/specs/:id/debug", async (c) => {
@@ -2256,21 +2613,7 @@ function createSpecReviewRoutes(resolveProject) {
2256
2613
  return c.json({ exists: false, text: "" });
2257
2614
  }
2258
2615
  });
2259
- app.get("/projects/:projectId/specs/:id/changes", async (c) => {
2260
- const p = await need(c);
2261
- if (p instanceof Response) return p;
2262
- const specId = c.req.param("id");
2263
- const detail = await p.store.read(specId);
2264
- if (!detail) return c.json({ error: "spec not found" }, 404);
2265
- try {
2266
- const changes = await listChanges(p.path);
2267
- return c.json({ changes });
2268
- } catch (err) {
2269
- if (err instanceof GitError) return c.json({ error: err.message }, 400);
2270
- throw err;
2271
- }
2272
- });
2273
- app.post("/projects/:projectId/specs/:id/commit", async (c) => {
2616
+ app.post("/projects/:projectId/specs/:id/stash", async (c) => {
2274
2617
  const p = await need(c);
2275
2618
  if (p instanceof Response) return p;
2276
2619
  const specId = c.req.param("id");
@@ -2285,30 +2628,122 @@ function createSpecReviewRoutes(resolveProject) {
2285
2628
  const obj = body && typeof body === "object" ? body : {};
2286
2629
  const message = typeof obj.message === "string" ? obj.message.trim() : "";
2287
2630
  const paths = Array.isArray(obj.paths) ? obj.paths.filter((v) => typeof v === "string") : [];
2288
- if (!message) return c.json({ error: "message must not be empty" }, 400);
2289
2631
  if (paths.length === 0) return c.json({ error: "paths must not be empty" }, 400);
2290
2632
  try {
2291
- const result = await commit(p.path, { message, paths });
2292
- return c.json(result);
2633
+ await stash(p.path, { message: message || `yorz:${specId}`, paths });
2634
+ return c.json({ ok: true });
2293
2635
  } catch (err) {
2294
2636
  if (err instanceof GitError) return c.json({ error: err.message }, 400);
2295
2637
  throw err;
2296
2638
  }
2297
2639
  });
2298
- app.post("/projects/:projectId/specs/:id/discard", async (c) => {
2640
+ return app;
2641
+ }
2642
+ function buildGitOpsPrompt(action, specId, specRel) {
2643
+ const base = `${skillRef("yorz-git-ops")},然后执行 git 操作:
2644
+ - spec 文档:${specRel}
2645
+ - 请依据 spec 文档与 git status/diff 自行判断本次 spec 关联的变更文件
2646
+ - spec-id:${specId}
2647
+ `;
2648
+ switch (action) {
2649
+ case "commit":
2650
+ return base + `- 动作:git-commit。请基于 spec 文档与 git status/diff,由你自主判断本次 spec 相关的变更文件,执行 \`git add\` + \`git commit\`;commit message 由你生成,不带 scope;禁止 \`git push\` 与 \`git reset --hard\`。
2651
+ `;
2652
+ case "discard":
2653
+ return base + `- 动作:git-discard。请使用 \`git restore --staged --worktree -- <paths>\` 与 \`git clean -fd -- <paths>\` 丢弃 spec 相关的未提交变更;对 untracked 新文件应先在终端输出列表再处理;不要预先 stash 备份;禁止 \`git reset --hard\` 与 \`git push\`。
2654
+ `;
2655
+ case "stash":
2656
+ return base + `- 动作:git-stash。请使用 \`git stash push -m "yorz:${specId}" -- <paths>\` 暂存 spec 相关变更文件;禁止 \`git push\`。
2657
+ `;
2658
+ }
2659
+ }
2660
+ function createGitRoutes(resolveProject) {
2661
+ const app = new Hono();
2662
+ const need = async (c) => {
2663
+ const id = c.req.param("projectId") ?? "";
2664
+ if (!id) return c.json({ error: "projectId required" }, 400);
2665
+ const project = await resolveProject(id);
2666
+ if (!project) return c.json({ error: "project not found" }, 404);
2667
+ return project;
2668
+ };
2669
+ const readBody = async (c) => {
2670
+ try {
2671
+ const body = await c.req.json();
2672
+ return body && typeof body === "object" ? body : {};
2673
+ } catch {
2674
+ return null;
2675
+ }
2676
+ };
2677
+ const toPaths = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
2678
+ app.get("/projects/:projectId/git/changes", async (c) => {
2299
2679
  const p = await need(c);
2300
2680
  if (p instanceof Response) return p;
2301
- const specId = c.req.param("id");
2302
- const detail = await p.store.read(specId);
2303
- if (!detail) return c.json({ error: "spec not found" }, 404);
2304
- let body;
2305
2681
  try {
2306
- body = await c.req.json();
2307
- } catch {
2308
- return c.json({ error: "invalid JSON body" }, 400);
2682
+ return c.json({ changes: await listChanges(p.path) });
2683
+ } catch (err) {
2684
+ if (err instanceof GitError) return c.json({ error: err.message }, 400);
2685
+ throw err;
2309
2686
  }
2310
- const obj = body && typeof body === "object" ? body : {};
2311
- const paths = Array.isArray(obj.paths) ? obj.paths.filter((v) => typeof v === "string") : [];
2687
+ });
2688
+ app.get("/projects/:projectId/git/diff", async (c) => {
2689
+ const p = await need(c);
2690
+ if (p instanceof Response) return p;
2691
+ const path = c.req.query("path") ?? "";
2692
+ if (!path) return c.json({ error: "path required" }, 400);
2693
+ try {
2694
+ return c.json(await fileDiff(p.path, path));
2695
+ } catch (err) {
2696
+ if (err instanceof GitError) return c.json({ error: err.message }, 400);
2697
+ throw err;
2698
+ }
2699
+ });
2700
+ app.get("/projects/:projectId/git/branches", async (c) => {
2701
+ const p = await need(c);
2702
+ if (p instanceof Response) return p;
2703
+ try {
2704
+ return c.json(await listBranches(p.path));
2705
+ } catch (err) {
2706
+ if (err instanceof GitError) return c.json({ error: err.message }, 400);
2707
+ throw err;
2708
+ }
2709
+ });
2710
+ app.post("/projects/:projectId/git/checkout", async (c) => {
2711
+ const p = await need(c);
2712
+ if (p instanceof Response) return p;
2713
+ const body = await readBody(c);
2714
+ if (!body) return c.json({ error: "invalid JSON body" }, 400);
2715
+ const branch = typeof body.branch === "string" ? body.branch.trim() : "";
2716
+ if (!branch) return c.json({ error: "branch must not be empty" }, 400);
2717
+ try {
2718
+ const result = await checkoutBranch(p.path, branch);
2719
+ return c.json({ ok: true, ...result });
2720
+ } catch (err) {
2721
+ if (err instanceof GitError) return c.json({ error: err.message }, 400);
2722
+ throw err;
2723
+ }
2724
+ });
2725
+ app.post("/projects/:projectId/git/commit", async (c) => {
2726
+ const p = await need(c);
2727
+ if (p instanceof Response) return p;
2728
+ const body = await readBody(c);
2729
+ if (!body) return c.json({ error: "invalid JSON body" }, 400);
2730
+ const message = typeof body.message === "string" ? body.message.trim() : "";
2731
+ const paths = toPaths(body.paths);
2732
+ if (!message) return c.json({ error: "message must not be empty" }, 400);
2733
+ if (paths.length === 0) return c.json({ error: "paths must not be empty" }, 400);
2734
+ try {
2735
+ return c.json(await commit(p.path, { message, paths }));
2736
+ } catch (err) {
2737
+ if (err instanceof GitError) return c.json({ error: err.message }, 400);
2738
+ throw err;
2739
+ }
2740
+ });
2741
+ app.post("/projects/:projectId/git/discard", async (c) => {
2742
+ const p = await need(c);
2743
+ if (p instanceof Response) return p;
2744
+ const body = await readBody(c);
2745
+ if (!body) return c.json({ error: "invalid JSON body" }, 400);
2746
+ const paths = toPaths(body.paths);
2312
2747
  if (paths.length === 0) return c.json({ error: "paths must not be empty" }, 400);
2313
2748
  try {
2314
2749
  await discard(p.path, { paths });
@@ -2318,25 +2753,23 @@ function createSpecReviewRoutes(resolveProject) {
2318
2753
  throw err;
2319
2754
  }
2320
2755
  });
2321
- app.post("/projects/:projectId/specs/:id/stash", async (c) => {
2756
+ app.post("/projects/:projectId/git/push", async (c) => {
2322
2757
  const p = await need(c);
2323
2758
  if (p instanceof Response) return p;
2324
- const specId = c.req.param("id");
2325
- const detail = await p.store.read(specId);
2326
- if (!detail) return c.json({ error: "spec not found" }, 404);
2327
- let body;
2328
2759
  try {
2329
- body = await c.req.json();
2330
- } catch {
2331
- return c.json({ error: "invalid JSON body" }, 400);
2760
+ const result = await push(p.path);
2761
+ return c.json({ ok: true, ...result });
2762
+ } catch (err) {
2763
+ if (err instanceof GitError) return c.json({ error: err.message }, 400);
2764
+ throw err;
2332
2765
  }
2333
- const obj = body && typeof body === "object" ? body : {};
2334
- const message = typeof obj.message === "string" ? obj.message.trim() : "";
2335
- const paths = Array.isArray(obj.paths) ? obj.paths.filter((v) => typeof v === "string") : [];
2336
- if (paths.length === 0) return c.json({ error: "paths must not be empty" }, 400);
2766
+ });
2767
+ app.post("/projects/:projectId/git/pull", async (c) => {
2768
+ const p = await need(c);
2769
+ if (p instanceof Response) return p;
2337
2770
  try {
2338
- await stash(p.path, { message: message || `yorz:${specId}`, paths });
2339
- return c.json({ ok: true });
2771
+ const result = await pull(p.path);
2772
+ return c.json({ ok: true, ...result });
2340
2773
  } catch (err) {
2341
2774
  if (err instanceof GitError) return c.json({ error: err.message }, 400);
2342
2775
  throw err;
@@ -2344,24 +2777,6 @@ function createSpecReviewRoutes(resolveProject) {
2344
2777
  });
2345
2778
  return app;
2346
2779
  }
2347
- function buildGitOpsPrompt(action, specId, specRel) {
2348
- const base = `${skillRef("yorz-git-ops")},然后执行 git 操作:
2349
- - spec 文档:${specRel}
2350
- - 请依据 spec 文档与 git status/diff 自行判断本次 spec 关联的变更文件
2351
- - spec-id:${specId}
2352
- `;
2353
- switch (action) {
2354
- case "commit":
2355
- return base + `- 动作:git-commit。请基于 spec 文档与 git status/diff,由你自主判断本次 spec 相关的变更文件,执行 \`git add\` + \`git commit\`;commit message 由你生成,不带 scope;禁止 \`git push\` 与 \`git reset --hard\`。
2356
- `;
2357
- case "discard":
2358
- return base + `- 动作:git-discard。请使用 \`git restore --staged --worktree -- <paths>\` 与 \`git clean -fd -- <paths>\` 丢弃 spec 相关的未提交变更;对 untracked 新文件应先在终端输出列表再处理;不要预先 stash 备份;禁止 \`git reset --hard\` 与 \`git push\`。
2359
- `;
2360
- case "stash":
2361
- return base + `- 动作:git-stash。请使用 \`git stash push -m "yorz:${specId}" -- <paths>\` 暂存 spec 相关变更文件;禁止 \`git push\`。
2362
- `;
2363
- }
2364
- }
2365
2780
  const sseLog = () => getLogger().child("sse");
2366
2781
  const HEARTBEAT_INTERVAL_MS = 5e3;
2367
2782
  function attachHeartbeat(stream, intervalMs = HEARTBEAT_INTERVAL_MS) {
@@ -2553,9 +2968,8 @@ class EventsHub {
2553
2968
  if (rest === "commands") return this.attachCommandRuns(s, topic, project);
2554
2969
  const cm = /^command:(.+)$/.exec(rest);
2555
2970
  if (cm) return this.attachCommandRun(s, topic, project, cm[1]);
2556
- let sm = /^spec:([^:]+):changes$/.exec(rest);
2557
- if (sm) return this.attachSpecChanges(s, topic, project, sm[1]);
2558
- sm = /^spec:([^:]+)$/.exec(rest);
2971
+ if (rest === "changes") return this.attachProjectChanges(s, topic, project);
2972
+ let sm = /^spec:([^:]+)$/.exec(rest);
2559
2973
  if (sm) return this.attachSpec(s, topic, project, sm[1]);
2560
2974
  sm = /^session:(.+)$/.exec(rest);
2561
2975
  if (sm) return this.attachSession(s, topic, project, sm[1]);
@@ -2593,9 +3007,12 @@ class EventsHub {
2593
3007
  this.emit(s, topic, "updated", { type: kind, mtime });
2594
3008
  });
2595
3009
  }
2596
- async attachSpecChanges(s, topic, project, specId) {
2597
- const exists = await project.store.read(specId);
2598
- if (!exists) throw new Error("spec not found");
3010
+ /**
3011
+ * Git changes are repository-wide: the watcher is keyed on the project path,
3012
+ * so this topic carries no spec id and both the standalone Git page and the
3013
+ * spec Review page share one subscription.
3014
+ */
3015
+ attachProjectChanges(s, topic, project) {
2599
3016
  this.emit(s, topic, "ready", {});
2600
3017
  const { current, unsubscribe } = subscribeGitChanges(project.path, (changes) => {
2601
3018
  this.emit(s, topic, "changes-updated", { changes });
@@ -3986,6 +4403,15 @@ class CommandManager {
3986
4403
  this.notifyRuns();
3987
4404
  this.notifyRun(next);
3988
4405
  log$3().info("command ended", { runId, status, exitCode, signal });
4406
+ getTelemetry(this.projectPath).record("cmd.exec", {
4407
+ commandId: next.commandId,
4408
+ name: next.name,
4409
+ runId,
4410
+ status,
4411
+ exitCode: exitCode ?? void 0,
4412
+ signal: signal ?? void 0,
4413
+ durMs: (next.endedAt ?? Date.now()) - next.startedAt
4414
+ });
3989
4415
  }
3990
4416
  notifyRuns() {
3991
4417
  if (this.runsSubscribers.size === 0) return;
@@ -4808,7 +5234,7 @@ function parseCommitLines(stdout) {
4808
5234
  function renderConflictSpec(args) {
4809
5235
  const { branch, report, now } = args;
4810
5236
  const today = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
4811
- const nowStamp = `${today} ${pad2(now.getHours())}:${pad2(now.getMinutes())}:${pad2(now.getSeconds())}`;
5237
+ const nowStamp2 = `${today} ${pad2(now.getHours())}:${pad2(now.getMinutes())}:${pad2(now.getSeconds())}`;
4812
5238
  const fileLines = report.files.length ? report.files.map((f) => `- \`${f}\``).join("\n") : "- (无 — 合并失败但未列出冲突文件,请人工核查)";
4813
5239
  const commitBlocks = report.files.length ? report.files.map((f) => {
4814
5240
  const commits = report.commitsByFile[f] ?? [];
@@ -4878,7 +5304,7 @@ _暂无_
4878
5304
  "---",
4879
5305
  "stage: plan",
4880
5306
  "last_action: 由 worktree 合并冲突自动生成",
4881
- `updated_at: '${nowStamp}'`,
5307
+ `updated_at: '${nowStamp2}'`,
4882
5308
  `summary: 解决 ${branch} 合并到主项目时产生的 ${report.files.length} 个冲突文件`,
4883
5309
  "---",
4884
5310
  ""
@@ -4934,7 +5360,9 @@ function createApp(opts) {
4934
5360
  const { sessionId } = await main.sessions.ensureSessionForSpec(specId);
4935
5361
  void main.sessions.send(
4936
5362
  sessionId,
4937
- `${skillRef("yorz-spec")},然后处理 spec:${main.specsDirRelative}/${specId}/spec.md`
5363
+ `${skillRef("yorz-spec")},然后处理 spec:${main.specsDirRelative}/${specId}/spec.md`,
5364
+ void 0,
5365
+ { trigger: "conflict", specId }
4938
5366
  );
4939
5367
  }
4940
5368
  });
@@ -4947,6 +5375,7 @@ function createApp(opts) {
4947
5375
  api.route("/", createSpecsRoutes(resolveProject));
4948
5376
  api.route("/", createSessionsRoutes(resolveProject));
4949
5377
  api.route("/", createSpecReviewRoutes(resolveProject));
5378
+ api.route("/", createGitRoutes(resolveProject));
4950
5379
  api.route("/", createSpecDraftsRoutes(resolveProject));
4951
5380
  api.route("/", createWorktreeRoutes(opts.registry, worktreeManager));
4952
5381
  api.route("/", createProjectFilesRoutes(resolveProject));
@@ -5420,6 +5849,7 @@ const watcherLog = () => getLogger().child("watcher");
5420
5849
  const UNLINK_SETTLE_MS = 80;
5421
5850
  class SpecWatcher {
5422
5851
  root;
5852
+ cwd;
5423
5853
  unlinkSettleMs;
5424
5854
  detailListeners = /* @__PURE__ */ new Map();
5425
5855
  listListeners = /* @__PURE__ */ new Set();
@@ -5430,6 +5860,7 @@ class SpecWatcher {
5430
5860
  watcher = null;
5431
5861
  constructor(opts) {
5432
5862
  this.root = opts.specsDir ?? join(opts.cwd, ".yorz", "specs");
5863
+ this.cwd = opts.cwd;
5433
5864
  this.unlinkSettleMs = opts.unlinkSettleMs ?? UNLINK_SETTLE_MS;
5434
5865
  }
5435
5866
  async start() {
@@ -5523,6 +5954,7 @@ class SpecWatcher {
5523
5954
  this.pendingUnlinks.set(id, timer);
5524
5955
  }
5525
5956
  emit(id, evt, mtimeMs) {
5957
+ getTelemetry(this.cwd).record("spec.change", { specId: id, kind: evt, mtimeMs });
5526
5958
  for (const cb of this.detailListeners.get(id) ?? []) cb(evt, mtimeMs);
5527
5959
  for (const cb of this.listListeners) cb();
5528
5960
  }
@@ -5586,6 +6018,29 @@ function usageWindows(usage) {
5586
6018
  }
5587
6019
  return out;
5588
6020
  }
6021
+ function num$1(value) {
6022
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
6023
+ }
6024
+ function isRecord(value) {
6025
+ return !!value && typeof value === "object" && !Array.isArray(value);
6026
+ }
6027
+ function withCost(usage, costUsd) {
6028
+ if (costUsd === void 0) return usage;
6029
+ return { ...usage ?? {}, costUsd };
6030
+ }
6031
+ function primaryModel(modelUsage) {
6032
+ if (!isRecord(modelUsage)) return void 0;
6033
+ let best;
6034
+ let bestOutput = -1;
6035
+ for (const [model, raw] of Object.entries(modelUsage)) {
6036
+ const output = isRecord(raw) ? num$1(raw.outputTokens) ?? 0 : 0;
6037
+ if (output > bestOutput) {
6038
+ bestOutput = output;
6039
+ best = model;
6040
+ }
6041
+ }
6042
+ return best;
6043
+ }
5589
6044
  class ClaudeSession {
5590
6045
  constructor(id, cwd, isNew) {
5591
6046
  this.id = id;
@@ -5612,6 +6067,7 @@ class ClaudeSession {
5612
6067
  try {
5613
6068
  const q = query({ prompt, options });
5614
6069
  let usage;
6070
+ let metrics;
5615
6071
  let completed = false;
5616
6072
  for await (const msg of q) {
5617
6073
  const m = msg;
@@ -5636,12 +6092,32 @@ class ClaudeSession {
5636
6092
  }
5637
6093
  } else if (m.type === "result") {
5638
6094
  usage = m.usage;
6095
+ metrics = {
6096
+ usage: withCost(normalizeUsage("claude", m.usage), num$1(m.total_cost_usd)),
6097
+ modelUsage: isRecord(m.modelUsage) ? m.modelUsage : void 0,
6098
+ numTurns: num$1(m.num_turns),
6099
+ stopReason: typeof m.stop_reason === "string" ? m.stop_reason : void 0,
6100
+ model: primaryModel(m.modelUsage),
6101
+ durationMs: num$1(m.duration_ms),
6102
+ apiDurationMs: num$1(m.duration_api_ms)
6103
+ };
6104
+ } else if (m.type === "system" && m.subtype === "compact_boundary") {
6105
+ const meta = m.compact_metadata ?? {};
6106
+ yield {
6107
+ type: "compact",
6108
+ metrics: {
6109
+ trigger: meta.trigger === "manual" || meta.trigger === "auto" ? meta.trigger : void 0,
6110
+ preTokens: num$1(meta.pre_tokens),
6111
+ postTokens: num$1(meta.post_tokens),
6112
+ durationMs: num$1(meta.duration_ms)
6113
+ }
6114
+ };
5639
6115
  } else if (m.type === "system" && m.subtype === "session_state_changed" && m.state === "idle") {
5640
6116
  completed = true;
5641
- yield { type: "turn-completed", usage };
6117
+ yield { type: "turn-completed", usage, metrics };
5642
6118
  }
5643
6119
  }
5644
- if (!completed) yield { type: "turn-completed", usage };
6120
+ if (!completed) yield { type: "turn-completed", usage, metrics };
5645
6121
  } catch (err) {
5646
6122
  if (ctrl.signal.aborted) return;
5647
6123
  yield { type: "error", message: err instanceof Error ? err.message : String(err) };
@@ -5924,7 +6400,11 @@ class CodexSession {
5924
6400
  yield { type: "error", message: item.message };
5925
6401
  }
5926
6402
  } else if (ev.type === "turn.completed") {
5927
- yield { type: "turn-completed", usage: ev.usage };
6403
+ yield {
6404
+ type: "turn-completed",
6405
+ usage: ev.usage,
6406
+ metrics: { usage: normalizeUsage("codex", ev.usage) }
6407
+ };
5928
6408
  } else if (ev.type === "turn.failed") {
5929
6409
  yield { type: "error", message: ev.error.message };
5930
6410
  } else if (ev.type === "error") {
@@ -6310,7 +6790,14 @@ class OpenCodeSession {
6310
6790
  for (const part of textParts(data.parts)) {
6311
6791
  if (part.type === "text") yield { type: "text", delta: part.text };
6312
6792
  }
6313
- yield { type: "turn-completed", usage: data.info.tokens };
6793
+ yield {
6794
+ type: "turn-completed",
6795
+ usage: data.info.tokens,
6796
+ metrics: {
6797
+ usage: normalizeUsage("opencode", data.info.tokens),
6798
+ model: typeof data.info.modelID === "string" ? data.info.modelID : void 0
6799
+ }
6800
+ };
6314
6801
  } catch (err) {
6315
6802
  if (this.aborted) return;
6316
6803
  yield { type: "error", message: err instanceof Error ? err.message : String(err) };
@@ -6678,9 +7165,11 @@ class SessionManager {
6678
7165
  * text while sending an expanded prompt to the Agent. Without it, an injected
6679
7166
  * hidden prompt would become the title.
6680
7167
  */
6681
- async send(sid, prompt, titleSource) {
7168
+ async send(sid, prompt, titleSource, meta = {}) {
6682
7169
  const runId = randomUUID();
6683
7170
  const emitter = this.emitterFor(sid);
7171
+ const telemetry = getTelemetry(this.cwd);
7172
+ const attribution = { specId: meta.specId, trigger: meta.trigger };
6684
7173
  let currentSid = sid;
6685
7174
  const startedAt = Date.now();
6686
7175
  await this.maybeUpdateTitleFromPrompt(currentSid, titleSource ?? prompt).catch(() => {
@@ -6689,18 +7178,57 @@ class SessionManager {
6689
7178
  });
6690
7179
  this.setRunning(sid, true);
6691
7180
  agentLog().info("dispatch start", { sessionId: sid, runId, promptLength: prompt.length });
7181
+ telemetry.record("agent.dispatch", {
7182
+ ...attribution,
7183
+ phase: "start",
7184
+ sessionId: sid,
7185
+ traceId: runId,
7186
+ promptLength: prompt.length
7187
+ });
7188
+ let agentKind;
7189
+ let dispatchError;
6692
7190
  const task = (async () => {
6693
7191
  try {
6694
7192
  const ls = await this.ensureLive(sid);
7193
+ agentKind = ls.kind;
6695
7194
  for await (const ev of ls.session.send(prompt)) {
6696
7195
  if (ev.type === "session-started" && ev.sessionId !== currentSid) {
6697
7196
  const oldSid = currentSid;
6698
7197
  currentSid = ev.sessionId;
6699
7198
  await this.reconcile(oldSid, ev.sessionId);
7199
+ } else if (ev.type === "turn-completed") {
7200
+ const m = ev.metrics ?? {};
7201
+ telemetry.record("agent.turn", {
7202
+ ...attribution,
7203
+ sessionId: currentSid,
7204
+ traceId: runId,
7205
+ agentKind,
7206
+ usage: m.usage,
7207
+ modelUsage: m.modelUsage,
7208
+ numTurns: m.numTurns,
7209
+ stopReason: m.stopReason,
7210
+ model: m.model,
7211
+ durMs: m.durationMs,
7212
+ apiDurMs: m.apiDurationMs
7213
+ });
7214
+ } else if (ev.type === "compact") {
7215
+ telemetry.record("agent.compact", {
7216
+ ...attribution,
7217
+ sessionId: currentSid,
7218
+ traceId: runId,
7219
+ agentKind,
7220
+ // Named apart from the dispatch `trigger` so both survive on the
7221
+ // same line: which action started the run, and what compacted it.
7222
+ compactTrigger: ev.metrics.trigger,
7223
+ preTokens: ev.metrics.preTokens,
7224
+ postTokens: ev.metrics.postTokens,
7225
+ durMs: ev.metrics.durationMs
7226
+ });
6700
7227
  }
6701
7228
  emitter.emit("event", ev);
6702
7229
  }
6703
7230
  } catch (err) {
7231
+ dispatchError = err instanceof Error ? err.message : String(err);
6704
7232
  agentLog().error("dispatch failed", {
6705
7233
  sessionId: currentSid,
6706
7234
  runId,
@@ -6718,6 +7246,17 @@ class SessionManager {
6718
7246
  runId,
6719
7247
  durationMs: Date.now() - startedAt
6720
7248
  });
7249
+ telemetry.record("agent.dispatch", {
7250
+ ...attribution,
7251
+ phase: "end",
7252
+ sessionId: currentSid,
7253
+ traceId: runId,
7254
+ agentKind,
7255
+ promptLength: prompt.length,
7256
+ durMs: Date.now() - startedAt,
7257
+ ok: dispatchError === void 0,
7258
+ errorMessage: dispatchError
7259
+ });
6721
7260
  await this.store.touch(currentSid).catch(() => {
6722
7261
  });
6723
7262
  this.setRunning(currentSid, false);
@@ -8909,6 +9448,7 @@ async function lintFile(path, opts = {}) {
8909
9448
  }
8910
9449
  [...new Set([...SPEC_RULES, ...REVIEW_RULES].map((r) => r.id))];
8911
9450
  async function runLint(opts) {
9451
+ const startedAt = Date.now();
8912
9452
  const cwd = resolve(opts.cwd);
8913
9453
  let targets = [];
8914
9454
  if (opts.all) {
@@ -8978,8 +9518,220 @@ async function runLint(opts) {
8978
9518
  Total: ${errorCount} error(s), ${warnCount} warn(s) across ${reports.length} file(s).
8979
9519
  `);
8980
9520
  }
9521
+ await recordLintRun(cwd, {
9522
+ fileCount: targets.length,
9523
+ errorCount,
9524
+ warnCount,
9525
+ exitCode,
9526
+ durMs: Date.now() - startedAt
9527
+ });
8981
9528
  return { reports, errorCount, warnCount, exitCode };
8982
9529
  }
9530
+ async function recordLintRun(cwd, payload) {
9531
+ const root = findProjectRoot(cwd);
9532
+ if (!root) return;
9533
+ const telemetry = getTelemetry(root);
9534
+ telemetry.record("lint.run", payload);
9535
+ await telemetry.flush();
9536
+ }
9537
+ const UNATTRIBUTED = "(no spec)";
9538
+ async function runMetrics(opts) {
9539
+ const dirs = await resolveTargetDirs(opts);
9540
+ if (dirs.length === 0) {
9541
+ process.stderr.write(
9542
+ "no telemetry found — run a dispatch first, or pass --project <id|path> / --all\n"
9543
+ );
9544
+ return { exitCode: 1, summary: emptySummary() };
9545
+ }
9546
+ const summary = emptySummary();
9547
+ for (const dir of dirs) {
9548
+ summary.projects.push(await projectLabel(dir));
9549
+ for (const file of await telemetryFiles(dir)) {
9550
+ summary.files.push(file);
9551
+ await consumeFile(file, opts.since, summary);
9552
+ }
9553
+ }
9554
+ summary.specs.sort((a, b) => b.costUsd - a.costUsd || b.turns - a.turns);
9555
+ if (opts.format === "json") {
9556
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}
9557
+ `);
9558
+ } else {
9559
+ process.stdout.write(renderText(summary));
9560
+ }
9561
+ return { exitCode: 0, summary };
9562
+ }
9563
+ async function resolveTargetDirs(opts) {
9564
+ const metricsDir = resolveMetricsDir();
9565
+ if (opts.all) {
9566
+ if (!existsSync(metricsDir)) return [];
9567
+ const entries = await readdir(metricsDir, { withFileTypes: true });
9568
+ return entries.filter((e) => e.isDirectory()).map((e) => join(metricsDir, e.name));
9569
+ }
9570
+ if (opts.project) {
9571
+ const dir2 = isAbsolute(opts.project) ? resolveProjectMetricsDir(opts.project) : join(metricsDir, opts.project);
9572
+ return existsSync(dir2) ? [dir2] : [];
9573
+ }
9574
+ const root = findProjectRoot(opts.cwd);
9575
+ if (!root) return [];
9576
+ const dir = resolveProjectMetricsDir(root);
9577
+ return existsSync(dir) ? [dir] : [];
9578
+ }
9579
+ async function telemetryFiles(dir) {
9580
+ if (!existsSync(dir)) return [];
9581
+ const entries = await readdir(dir);
9582
+ const archives = entries.filter((name2) => name2.startsWith(`${TELEMETRY_FILE_NAME}.`)).sort((a, b) => Number(b.split(".").pop()) - Number(a.split(".").pop())).map((name2) => join(dir, name2));
9583
+ const current = join(dir, TELEMETRY_FILE_NAME);
9584
+ return existsSync(current) ? [...archives, current] : archives;
9585
+ }
9586
+ async function projectLabel(dir) {
9587
+ try {
9588
+ const raw = await readFile(join(dir, PROJECT_META_FILE_NAME), "utf8");
9589
+ const meta = JSON.parse(raw);
9590
+ return meta.path ? `${meta.id ?? "?"} (${meta.path})` : meta.id ?? dir;
9591
+ } catch {
9592
+ return dir;
9593
+ }
9594
+ }
9595
+ async function consumeFile(file, since, out) {
9596
+ let raw;
9597
+ try {
9598
+ raw = await readFile(file, "utf8");
9599
+ } catch {
9600
+ return;
9601
+ }
9602
+ for (const line of raw.split("\n")) {
9603
+ if (!line.trim()) continue;
9604
+ let ev;
9605
+ try {
9606
+ ev = JSON.parse(line);
9607
+ } catch {
9608
+ out.skipped += 1;
9609
+ continue;
9610
+ }
9611
+ const ts = typeof ev.ts === "string" ? ev.ts : "";
9612
+ if (since && ts.slice(0, 10) < since) continue;
9613
+ out.lines += 1;
9614
+ const event = typeof ev.event === "string" ? ev.event : "unknown";
9615
+ out.eventCounts[event] = (out.eventCounts[event] ?? 0) + 1;
9616
+ applyEvent(ev, event, out);
9617
+ }
9618
+ }
9619
+ function applyEvent(ev, event, out) {
9620
+ if (!event.startsWith("agent.")) return;
9621
+ const bucket = bucketFor(out, typeof ev.specId === "string" ? ev.specId : UNATTRIBUTED);
9622
+ if (event === "agent.dispatch") {
9623
+ if (ev.phase !== "end") return;
9624
+ bucket.dispatches += 1;
9625
+ out.totals.dispatches += 1;
9626
+ if (ev.ok === false) {
9627
+ bucket.failures += 1;
9628
+ out.totals.failures += 1;
9629
+ }
9630
+ return;
9631
+ }
9632
+ if (event === "agent.compact") {
9633
+ bucket.compactions += 1;
9634
+ out.totals.compactions += 1;
9635
+ return;
9636
+ }
9637
+ if (event !== "agent.turn") return;
9638
+ bucket.turns += 1;
9639
+ out.totals.turns += 1;
9640
+ const durMs = num(ev.durMs);
9641
+ bucket.agentDurMs += durMs;
9642
+ out.totals.agentDurMs += durMs;
9643
+ const usage = ev.usage ?? {};
9644
+ for (const key of [
9645
+ "inputTokens",
9646
+ "cacheReadTokens",
9647
+ "cacheCreateTokens",
9648
+ "outputTokens"
9649
+ ]) {
9650
+ bucket[key] += num(usage[key]);
9651
+ out.totals[key] += num(usage[key]);
9652
+ }
9653
+ bucket.costUsd += num(usage.costUsd);
9654
+ out.totals.costUsd += num(usage.costUsd);
9655
+ }
9656
+ function bucketFor(out, specId) {
9657
+ let bucket = out.specs.find((s) => s.specId === specId);
9658
+ if (!bucket) {
9659
+ bucket = {
9660
+ specId,
9661
+ dispatches: 0,
9662
+ turns: 0,
9663
+ compactions: 0,
9664
+ inputTokens: 0,
9665
+ cacheReadTokens: 0,
9666
+ cacheCreateTokens: 0,
9667
+ outputTokens: 0,
9668
+ costUsd: 0,
9669
+ agentDurMs: 0,
9670
+ failures: 0,
9671
+ stages: []
9672
+ };
9673
+ out.specs.push(bucket);
9674
+ }
9675
+ return bucket;
9676
+ }
9677
+ function num(value) {
9678
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
9679
+ }
9680
+ function emptySummary() {
9681
+ return {
9682
+ projects: [],
9683
+ files: [],
9684
+ lines: 0,
9685
+ skipped: 0,
9686
+ eventCounts: {},
9687
+ totals: {
9688
+ dispatches: 0,
9689
+ turns: 0,
9690
+ compactions: 0,
9691
+ inputTokens: 0,
9692
+ cacheReadTokens: 0,
9693
+ cacheCreateTokens: 0,
9694
+ outputTokens: 0,
9695
+ costUsd: 0,
9696
+ agentDurMs: 0,
9697
+ failures: 0
9698
+ },
9699
+ specs: []
9700
+ };
9701
+ }
9702
+ const SPEC_ROW_LIMIT = 20;
9703
+ function renderText(s) {
9704
+ const lines = [];
9705
+ lines.push(`projects: ${s.projects.join(", ") || "(none)"}`);
9706
+ lines.push(`events: ${s.lines}${s.skipped ? ` (${s.skipped} unparseable line(s))` : ""}`);
9707
+ const kinds = Object.entries(s.eventCounts).sort((a, b) => b[1] - a[1]);
9708
+ if (kinds.length) {
9709
+ lines.push(`by kind: ${kinds.map(([k, n]) => `${k}=${n}`).join(" ")}`);
9710
+ }
9711
+ const t = s.totals;
9712
+ lines.push("");
9713
+ lines.push(
9714
+ `total: ${t.dispatches} dispatch(es), ${t.turns} turn(s), ${t.failures} failure(s), ${t.compactions} compaction(s)`
9715
+ );
9716
+ lines.push(
9717
+ `tokens: in=${t.inputTokens} cacheRead=${t.cacheReadTokens} cacheWrite=${t.cacheCreateTokens} out=${t.outputTokens}`
9718
+ );
9719
+ lines.push(`cost: $${t.costUsd.toFixed(4)} agent time: ${(t.agentDurMs / 1e3).toFixed(1)}s`);
9720
+ if (s.specs.length) {
9721
+ lines.push("");
9722
+ lines.push("per spec (by cost):");
9723
+ for (const spec of s.specs.slice(0, SPEC_ROW_LIMIT)) {
9724
+ lines.push(
9725
+ ` ${spec.specId.padEnd(40)} $${spec.costUsd.toFixed(4).padStart(9)} ${String(spec.dispatches).padStart(3)} disp ${String(spec.turns).padStart(3)} turn in=${spec.inputTokens} cacheRead=${spec.cacheReadTokens} out=${spec.outputTokens}`
9726
+ );
9727
+ }
9728
+ if (s.specs.length > SPEC_ROW_LIMIT) {
9729
+ lines.push(` … ${s.specs.length - SPEC_ROW_LIMIT} more spec(s) not shown (use --format json)`);
9730
+ }
9731
+ }
9732
+ return `${lines.join("\n")}
9733
+ `;
9734
+ }
8983
9735
  const program = new Command();
8984
9736
  program.name("yorz").description("YorZ CLI — manage the yorz-spec skill.").version(pkg.version);
8985
9737
  const uninstallCmd = program.command("uninstall").description("Remove YorZ artifacts (skills, ...).");
@@ -9043,6 +9795,18 @@ program.command("lint [paths...]").description("Lint spec.md / review.md files f
9043
9795
  if (result.exitCode !== 0) process.exit(result.exitCode);
9044
9796
  }
9045
9797
  );
9798
+ program.command("metrics").description("Summarize collected telemetry (token / cost / duration per spec).").option("--format <format>", "output format: text | json", "text").option("--project <idOrPath>", "project id or absolute path (default: project of --cwd)").option("--all", "aggregate across every project that has telemetry", false).option("--since <date>", "only events on or after this local date (YYYY-MM-DD)").option("--cwd <path>", "working directory (default: process.cwd())").action(
9799
+ async (opts) => {
9800
+ const result = await runMetrics({
9801
+ cwd: opts.cwd ?? process.cwd(),
9802
+ format: opts.format === "json" ? "json" : "text",
9803
+ project: opts.project,
9804
+ all: opts.all === true,
9805
+ since: opts.since
9806
+ });
9807
+ if (result.exitCode !== 0) process.exit(result.exitCode);
9808
+ }
9809
+ );
9046
9810
  const serveCmd = program.command("serve").description("Start the YorZ Service (HTTP + SSE + static GUI; multi-project).").option("-p, --port <port>", "port to listen on", "7423").option(
9047
9811
  "--host <address>",
9048
9812
  "bind address (default: 127.0.0.1; the service runs shell commands, so exposing it to the network is opt-in)"