@yorz/cli 0.7.1 → 0.8.1

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 (67) hide show
  1. package/dist/cli/index.js +982 -83
  2. package/dist/gui/assets/{abnfDiagram-VRR7QNED-Bfi5VmJM.js → abnfDiagram-VRR7QNED-D-y0QKuD.js} +1 -1
  3. package/dist/gui/assets/{arc-DW35HiMx.js → arc-B49ieWDx.js} +1 -1
  4. package/dist/gui/assets/{architectureDiagram-ZJ3FMSHR-D6ol2HjT.js → architectureDiagram-ZJ3FMSHR-CcOou6CY.js} +1 -1
  5. package/dist/gui/assets/{blockDiagram-677ZJIJ3-DimsChzs.js → blockDiagram-677ZJIJ3-DY8EHGpO.js} +1 -1
  6. package/dist/gui/assets/{c4Diagram-LMCZKHZV-aQtOPEhO.js → c4Diagram-LMCZKHZV-CV8mRhow.js} +1 -1
  7. package/dist/gui/assets/channel-BakWbuVQ.js +1 -0
  8. package/dist/gui/assets/{chunk-2Q5K7J3B-Bg7XMADz.js → chunk-2Q5K7J3B-BjplUf2p.js} +1 -1
  9. package/dist/gui/assets/{chunk-32BRIVSS-CJvj41Qg.js → chunk-32BRIVSS-Dua0UKp_.js} +1 -1
  10. package/dist/gui/assets/{chunk-5VM5RSS4-B6iQkE6V.js → chunk-5VM5RSS4-Br-SORjV.js} +1 -1
  11. package/dist/gui/assets/{chunk-EX3LRPZG-Bime3fML.js → chunk-EX3LRPZG-Dgb6iahE.js} +1 -1
  12. package/dist/gui/assets/{chunk-JWPE2WC7-B2d6pOPL.js → chunk-JWPE2WC7-BkOQaZRq.js} +1 -1
  13. package/dist/gui/assets/{chunk-MOJQB5TN-C2BtAAqD.js → chunk-MOJQB5TN-D1qUO9Uh.js} +1 -1
  14. package/dist/gui/assets/{chunk-RYQCIY6F-DvOYjxsw.js → chunk-RYQCIY6F-CwiUC3Pd.js} +1 -1
  15. package/dist/gui/assets/{chunk-V7JOEXUC-HRF7SSaJ.js → chunk-V7JOEXUC-O0VQnEjo.js} +1 -1
  16. package/dist/gui/assets/{chunk-VR4S4FIN-oMVpMTS0.js → chunk-VR4S4FIN-C-iULcHx.js} +1 -1
  17. package/dist/gui/assets/{chunk-XXDRQBXY-Gx6kiEHQ.js → chunk-XXDRQBXY-buihollE.js} +1 -1
  18. package/dist/gui/assets/classDiagram-OUVF2IWQ-DgfCpbC5.js +1 -0
  19. package/dist/gui/assets/classDiagram-v2-EOCWNBFH-DgfCpbC5.js +1 -0
  20. package/dist/gui/assets/{cose-bilkent-JH36ORCC-MoHCpl37.js → cose-bilkent-JH36ORCC-DJT3GZ0Y.js} +1 -1
  21. package/dist/gui/assets/{cynefin-VYW2F7L2-2tnFSmNB.js → cynefin-VYW2F7L2-CUB4SFmD.js} +1 -1
  22. package/dist/gui/assets/{cynefinDiagram-TSTJHNR4-Cp7uDUjl.js → cynefinDiagram-TSTJHNR4-Dt0_fxzu.js} +1 -1
  23. package/dist/gui/assets/{dagre-VKFMJZFB-CTNuVp99.js → dagre-VKFMJZFB-BU2VgIkF.js} +1 -1
  24. package/dist/gui/assets/{diagram-FQU43EPY-Z9nTO-2q.js → diagram-FQU43EPY-CpRyd1t3.js} +1 -1
  25. package/dist/gui/assets/{diagram-G47NLZAW-D1H3xA3x.js → diagram-G47NLZAW-CcJkOrvN.js} +1 -1
  26. package/dist/gui/assets/{diagram-NH7WQ7WH-BGpM31nf.js → diagram-NH7WQ7WH-BcWa9fg4.js} +1 -1
  27. package/dist/gui/assets/{diagram-OA4YK3LP-Bhn6yNbV.js → diagram-OA4YK3LP-Clu9LYE6.js} +1 -1
  28. package/dist/gui/assets/{diagram-WEI45ONY-BDJ6eaig.js → diagram-WEI45ONY-BKt73qmr.js} +1 -1
  29. package/dist/gui/assets/{ebnfDiagram-CCIWWBDH-C8UHOTF4.js → ebnfDiagram-CCIWWBDH-ycI3xDTZ.js} +1 -1
  30. package/dist/gui/assets/{erDiagram-Q63AITRT-BpoLARsO.js → erDiagram-Q63AITRT-CJf0jIvL.js} +1 -1
  31. package/dist/gui/assets/{flowDiagram-23GEKE2U-DSODAemd.js → flowDiagram-23GEKE2U-8X7ZYojT.js} +1 -1
  32. package/dist/gui/assets/{ganttDiagram-NO4QXBWP-Bv5JA_dU.js → ganttDiagram-NO4QXBWP-DsGJIvEc.js} +1 -1
  33. package/dist/gui/assets/{gitGraphDiagram-IHSO6WYX-CW7ObzYy.js → gitGraphDiagram-IHSO6WYX-Dg-PnhMR.js} +1 -1
  34. package/dist/gui/assets/index-DGSN0uvJ.css +1 -0
  35. package/dist/gui/assets/index-DIKCFt79.js +39 -0
  36. package/dist/gui/assets/{infoDiagram-FWYZ7A6U-uTM_03X9.js → infoDiagram-FWYZ7A6U-CX-PGRjw.js} +1 -1
  37. package/dist/gui/assets/{ishikawaDiagram-FXEZZL3T-BlbCXOf8.js → ishikawaDiagram-FXEZZL3T-BGq-1WGF.js} +1 -1
  38. package/dist/gui/assets/{journeyDiagram-5HDEW3XC-BwtfR_HU.js → journeyDiagram-5HDEW3XC-CX7J6QLx.js} +1 -1
  39. package/dist/gui/assets/{kanban-definition-HUTT4EX6-BXwvYv3u.js → kanban-definition-HUTT4EX6-XcUXgRy6.js} +1 -1
  40. package/dist/gui/assets/{linear-C5c8PyN7.js → linear-D-w3YyOR.js} +1 -1
  41. package/dist/gui/assets/{mermaid.core-Dbw2b69i.js → mermaid.core-BjB4Bulu.js} +4 -4
  42. package/dist/gui/assets/{mindmap-definition-LN4V7U3C-9P8fXZBY.js → mindmap-definition-LN4V7U3C-D2kDYOXu.js} +1 -1
  43. package/dist/gui/assets/{pegDiagram-2B236MQR-81kUPbkr.js → pegDiagram-2B236MQR-C79JhnNB.js} +1 -1
  44. package/dist/gui/assets/{pieDiagram-ENE6RG2P-BI7iwgtu.js → pieDiagram-ENE6RG2P-CbLYFwp4.js} +1 -1
  45. package/dist/gui/assets/{quadrantDiagram-ABIIQ3AL-CSxA_Epi.js → quadrantDiagram-ABIIQ3AL-BXNa6Xix.js} +1 -1
  46. package/dist/gui/assets/{railroadDiagram-RFXS5EU6-D4DnN2WS.js → railroadDiagram-RFXS5EU6-CG8ojk-9.js} +1 -1
  47. package/dist/gui/assets/{requirementDiagram-TGXJPOKE-DjNONyxQ.js → requirementDiagram-TGXJPOKE-Ddn0mejD.js} +1 -1
  48. package/dist/gui/assets/{sankeyDiagram-HTMAVEWB-Di4S5cUR.js → sankeyDiagram-HTMAVEWB-DXVSB34u.js} +1 -1
  49. package/dist/gui/assets/{sequenceDiagram-DBY2YBRQ-CLVwUzez.js → sequenceDiagram-DBY2YBRQ-B00FxwKQ.js} +1 -1
  50. package/dist/gui/assets/{sizeCapture-X5ZJPWSS-C5y0Ry_b.js → sizeCapture-X5ZJPWSS-BUiEsqdO.js} +1 -1
  51. package/dist/gui/assets/{stateDiagram-2N3HPSRC-IdHsn6JH.js → stateDiagram-2N3HPSRC-DflPz4MT.js} +1 -1
  52. package/dist/gui/assets/stateDiagram-v2-6OUMAXLB-DQohEYjS.js +1 -0
  53. package/dist/gui/assets/{swimlanes-5IMT3BWC-bDCHEODt.js → swimlanes-5IMT3BWC-yL7Pms6k.js} +2 -2
  54. package/dist/gui/assets/swimlanesDiagram-G3AALYLV-CUZb2EKw.js +8 -0
  55. package/dist/gui/assets/{timeline-definition-FHXFAJF6-B6f6vq0E.js → timeline-definition-FHXFAJF6--V8N1aYE.js} +1 -1
  56. package/dist/gui/assets/{vennDiagram-L72KCM5P-AjYH8jaU.js → vennDiagram-L72KCM5P-CjlyPLPm.js} +1 -1
  57. package/dist/gui/assets/{wardleyDiagram-EHGQE667-NV3g14vB.js → wardleyDiagram-EHGQE667-C99qPu9U.js} +1 -1
  58. package/dist/gui/assets/{xychartDiagram-FW5EYKEG-D4yDWaI8.js → xychartDiagram-FW5EYKEG-DeUNfALY.js} +1 -1
  59. package/dist/gui/index.html +24 -8
  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/index-Cft6LQWD.css +0 -1
  66. package/dist/gui/assets/stateDiagram-v2-6OUMAXLB-CJBs-brH.js +0 -1
  67. 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.1";
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,
@@ -373,10 +373,19 @@ function normalizeWorktree(value) {
373
373
  if (cleanSlug) meta.cleanSlug = cleanSlug;
374
374
  return meta;
375
375
  }
376
- function generateProjectId(absPath) {
377
- const base = basename$1(absPath);
378
- const slug = slugify(base) || "proj";
376
+ const WORKTREE_DIR_SUFFIX = ".wt";
377
+ const MAX_WORKTREE_NESTING = 4;
378
+ function resolveWorktreeMainPath(absPath) {
379
+ const parent = dirname(absPath);
380
+ const parentName = basename$1(parent);
381
+ if (!parentName.endsWith(WORKTREE_DIR_SUFFIX) || parentName === WORKTREE_DIR_SUFFIX) return null;
382
+ return join(dirname(parent), parentName.slice(0, -WORKTREE_DIR_SUFFIX.length));
383
+ }
384
+ function generateProjectId(absPath, depth = 0) {
379
385
  const hash = createHash("sha256").update(absPath).digest("hex").slice(0, 6);
386
+ const mainPath = depth < MAX_WORKTREE_NESTING ? resolveWorktreeMainPath(absPath) : null;
387
+ if (mainPath) return `${generateProjectId(mainPath, depth + 1)}_wt-${hash}`;
388
+ const slug = slugify(basename$1(absPath)) || "proj";
380
389
  return `${slug}-${hash}`;
381
390
  }
382
391
  function slugify(text) {
@@ -1199,6 +1208,336 @@ function skillEntryPath(skillName, env = process.env) {
1199
1208
  function skillRef(skillName, env = process.env) {
1200
1209
  return `请先完整阅读并严格遵循 ${skillEntryPath(skillName, env)}(YorZ 内置 ${skillName} skill 规则)`;
1201
1210
  }
1211
+ const METRICS_DIR_NAME = "metrics";
1212
+ const TELEMETRY_FILE_NAME = "telemetry.jsonl";
1213
+ const PROJECTS_INDEX_FILE_NAME = "projects.json";
1214
+ const TELEMETRY_RETENTION_DAYS = 365;
1215
+ const TELEMETRY_RETENTION_MS = TELEMETRY_RETENTION_DAYS * 24 * 60 * 60 * 1e3;
1216
+ const TELEMETRY_MAX_BYTES = Number.POSITIVE_INFINITY;
1217
+ const TELEMETRY_MAX_ARCHIVES = 0;
1218
+ function resolveMetricsDir(env = process.env) {
1219
+ return join(resolveGlobalConfigDir(env), METRICS_DIR_NAME);
1220
+ }
1221
+ function resolveTelemetryFile(env = process.env) {
1222
+ return join(resolveMetricsDir(env), TELEMETRY_FILE_NAME);
1223
+ }
1224
+ function resolveProjectsIndexFile(env = process.env) {
1225
+ return join(resolveMetricsDir(env), PROJECTS_INDEX_FILE_NAME);
1226
+ }
1227
+ function findProjectRoot(startDir) {
1228
+ let dir = resolve(startDir);
1229
+ for (; ; ) {
1230
+ if (existsSync(join(dir, ".yorz"))) return dir;
1231
+ const parent = dirname(dir);
1232
+ if (parent === dir) return null;
1233
+ dir = parent;
1234
+ }
1235
+ }
1236
+ function normalizeUsage(kind, raw) {
1237
+ if (!raw || typeof raw !== "object") return void 0;
1238
+ const obj = raw;
1239
+ const snapshot = kind === "codex" ? fromCodex(obj) : kind === "opencode" ? fromOpenCode(obj) : fromClaude(obj);
1240
+ return isEmpty(snapshot) ? void 0 : snapshot;
1241
+ }
1242
+ function fromClaude(u) {
1243
+ return compact({
1244
+ inputTokens: num$2(u.input_tokens),
1245
+ outputTokens: num$2(u.output_tokens),
1246
+ cacheReadTokens: num$2(u.cache_read_input_tokens),
1247
+ cacheCreateTokens: num$2(u.cache_creation_input_tokens)
1248
+ });
1249
+ }
1250
+ function fromCodex(u) {
1251
+ const total = num$2(u.input_tokens);
1252
+ const cached = num$2(u.cached_input_tokens);
1253
+ const uncached = total !== void 0 && cached !== void 0 ? Math.max(total - cached, 0) : total;
1254
+ return compact({
1255
+ inputTokens: uncached,
1256
+ cacheReadTokens: cached,
1257
+ outputTokens: num$2(u.output_tokens),
1258
+ reasoningTokens: num$2(u.reasoning_output_tokens)
1259
+ });
1260
+ }
1261
+ function fromOpenCode(u) {
1262
+ const cache = u.cache && typeof u.cache === "object" ? u.cache : {};
1263
+ return compact({
1264
+ inputTokens: num$2(u.input),
1265
+ outputTokens: num$2(u.output),
1266
+ reasoningTokens: num$2(u.reasoning),
1267
+ cacheReadTokens: num$2(cache.read),
1268
+ cacheCreateTokens: num$2(cache.write)
1269
+ });
1270
+ }
1271
+ function num$2(value) {
1272
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1273
+ }
1274
+ function compact(snapshot) {
1275
+ const out = {};
1276
+ for (const [key, value] of Object.entries(snapshot)) {
1277
+ if (value !== void 0) out[key] = value;
1278
+ }
1279
+ return out;
1280
+ }
1281
+ function isEmpty(snapshot) {
1282
+ return Object.keys(snapshot).length === 0;
1283
+ }
1284
+ const TELEMETRY_SCHEMA_VERSION = 2;
1285
+ function isTelemetryEnabled(env = process.env) {
1286
+ const raw = env.YORZ_TELEMETRY?.trim().toLowerCase();
1287
+ if (!raw) return true;
1288
+ return !["off", "0", "false", "no", "disabled"].includes(raw);
1289
+ }
1290
+ const sinks = /* @__PURE__ */ new Map();
1291
+ const indexQueues = /* @__PURE__ */ new Map();
1292
+ function enqueueIndexUpdate(file, task) {
1293
+ const next = (indexQueues.get(file) ?? Promise.resolve()).then(task).catch(() => {
1294
+ });
1295
+ indexQueues.set(file, next);
1296
+ }
1297
+ function getSink(dir) {
1298
+ let sink = sinks.get(dir);
1299
+ if (!sink) {
1300
+ sink = new RotatingFileSink(dir, TELEMETRY_FILE_NAME, TELEMETRY_MAX_BYTES, TELEMETRY_MAX_ARCHIVES);
1301
+ sinks.set(dir, sink);
1302
+ }
1303
+ return sink;
1304
+ }
1305
+ class TelemetryRecorder {
1306
+ constructor(projectRoot, projectId, dir, enabled, indexFile) {
1307
+ this.projectRoot = projectRoot;
1308
+ this.projectId = projectId;
1309
+ this.dir = dir;
1310
+ this.enabled = enabled;
1311
+ this.indexFile = indexFile;
1312
+ this.sink = enabled ? getSink(dir) : null;
1313
+ }
1314
+ sink;
1315
+ indexWritten = false;
1316
+ get filePath() {
1317
+ return join(this.dir, TELEMETRY_FILE_NAME);
1318
+ }
1319
+ /** Queue one event. Returns immediately; never throws. */
1320
+ record(event, payload = {}) {
1321
+ if (!this.sink) return;
1322
+ let line;
1323
+ try {
1324
+ const envelope = {
1325
+ v: TELEMETRY_SCHEMA_VERSION,
1326
+ ts: Date.now(),
1327
+ event,
1328
+ projectId: this.projectId,
1329
+ ...payload
1330
+ };
1331
+ line = `${JSON.stringify(envelope)}
1332
+ `;
1333
+ } catch {
1334
+ return;
1335
+ }
1336
+ this.sink.write(line);
1337
+ this.ensureIndexed();
1338
+ }
1339
+ /** Resolve once every queued line has hit the disk (tests / shutdown). */
1340
+ async flush() {
1341
+ await this.sink?.flush();
1342
+ await indexQueues.get(this.indexFile);
1343
+ }
1344
+ /** Merge this project into `projects.json` once per process. Best-effort. */
1345
+ ensureIndexed() {
1346
+ if (this.indexWritten || !this.sink) return;
1347
+ this.indexWritten = true;
1348
+ const { projectId, projectRoot, indexFile } = this;
1349
+ enqueueIndexUpdate(indexFile, async () => {
1350
+ let index = {};
1351
+ try {
1352
+ index = JSON.parse(await readFile(indexFile, "utf8"));
1353
+ } catch {
1354
+ }
1355
+ const existing = index[projectId];
1356
+ if (existing?.path === projectRoot) return;
1357
+ index[projectId] = {
1358
+ path: projectRoot,
1359
+ firstSeenAt: existing?.firstSeenAt ?? Date.now()
1360
+ };
1361
+ await writeFile(indexFile, `${JSON.stringify(index, null, 2)}
1362
+ `);
1363
+ });
1364
+ }
1365
+ }
1366
+ const recorders = /* @__PURE__ */ new Map();
1367
+ function getTelemetry(projectRoot, env = process.env) {
1368
+ const root = resolve(projectRoot);
1369
+ const cached = recorders.get(root);
1370
+ if (cached) return cached;
1371
+ const recorder = new TelemetryRecorder(
1372
+ root,
1373
+ generateProjectId(root),
1374
+ resolveMetricsDir(env),
1375
+ isTelemetryEnabled(env),
1376
+ resolveProjectsIndexFile(env)
1377
+ );
1378
+ recorders.set(root, recorder);
1379
+ return recorder;
1380
+ }
1381
+ const TASK_LINE = /^- \[( |x|X)\]/gm;
1382
+ function snapshotSpec(detail) {
1383
+ if (!detail) return null;
1384
+ let tasksTotal = 0;
1385
+ let tasksDone = 0;
1386
+ for (const match of detail.body.matchAll(TASK_LINE)) {
1387
+ tasksTotal += 1;
1388
+ if (match[1] !== " ") tasksDone += 1;
1389
+ }
1390
+ return {
1391
+ stage: detail.frontmatter.stage,
1392
+ tasksTotal,
1393
+ tasksDone,
1394
+ specBytes: Buffer.byteLength(detail.body, "utf8")
1395
+ };
1396
+ }
1397
+ function trackSpecStage(opts) {
1398
+ const { projectRoot, store, specId, handle, before, trigger } = opts;
1399
+ const beforeSnapshot = snapshotSpec(before);
1400
+ handle.onDone(() => {
1401
+ void (async () => {
1402
+ let afterSnapshot = null;
1403
+ try {
1404
+ afterSnapshot = snapshotSpec(await store.read(specId));
1405
+ } catch {
1406
+ }
1407
+ const payload = {
1408
+ specId,
1409
+ trigger,
1410
+ traceId: handle.runId,
1411
+ sessionId: handle.sessionId,
1412
+ stageBefore: beforeSnapshot?.stage,
1413
+ stageAfter: afterSnapshot?.stage,
1414
+ tasksTotal: afterSnapshot?.tasksTotal,
1415
+ tasksDone: afterSnapshot?.tasksDone,
1416
+ tasksDoneBefore: beforeSnapshot?.tasksDone,
1417
+ specBytes: afterSnapshot?.specBytes
1418
+ };
1419
+ getTelemetry(projectRoot).record("spec.stage", payload);
1420
+ })();
1421
+ });
1422
+ }
1423
+ const RETIRED_EVENTS = /* @__PURE__ */ new Set(["git.op", "spec.change"]);
1424
+ async function initTelemetryStore(opts = {}) {
1425
+ const env = opts.env ?? process.env;
1426
+ const now = opts.now ?? Date.now();
1427
+ const cutoff = now - (opts.retentionMs ?? TELEMETRY_RETENTION_MS);
1428
+ const stats = { migrated: 0, kept: 0, dropped: 0, legacyDirs: 0 };
1429
+ const dir = resolveMetricsDir(env);
1430
+ if (!existsSync(dir)) return stats;
1431
+ const rows = [];
1432
+ collect(await readLines(resolveTelemetryFile(env)), cutoff, rows, stats);
1433
+ const migratedBefore = rows.length;
1434
+ await migrateLegacyDirs(dir, env, cutoff, rows, stats);
1435
+ stats.migrated = rows.length - migratedBefore;
1436
+ rows.sort((a, b) => a.ts - b.ts);
1437
+ stats.kept = rows.length;
1438
+ if (rows.length === 0 && stats.dropped === 0) return stats;
1439
+ try {
1440
+ await writeAtomic(resolveTelemetryFile(env), rows.map((r) => `${JSON.stringify(r)}
1441
+ `).join(""));
1442
+ } catch {
1443
+ }
1444
+ return stats;
1445
+ }
1446
+ async function migrateLegacyDirs(metricsDir, env, cutoff, rows, stats) {
1447
+ let entries;
1448
+ try {
1449
+ entries = (await readdir(metricsDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
1450
+ } catch {
1451
+ return;
1452
+ }
1453
+ if (entries.length === 0) return;
1454
+ const index = await readIndex(env);
1455
+ for (const projectId of entries) {
1456
+ const legacyDir = join(metricsDir, projectId);
1457
+ for (const file of await legacyFiles(legacyDir)) {
1458
+ collect(await readLines(file), cutoff, rows, stats, projectId);
1459
+ }
1460
+ const meta = await readLegacyMeta(legacyDir);
1461
+ if (meta && !index[projectId]) {
1462
+ index[projectId] = { path: meta.path, firstSeenAt: meta.firstSeenAt };
1463
+ }
1464
+ try {
1465
+ await rm(legacyDir, { recursive: true, force: true });
1466
+ stats.legacyDirs += 1;
1467
+ } catch {
1468
+ }
1469
+ }
1470
+ await writeIndex(env, index);
1471
+ }
1472
+ async function legacyFiles(dir) {
1473
+ try {
1474
+ return (await readdir(dir)).filter((name2) => name2 === TELEMETRY_FILE_NAME || name2.startsWith(`${TELEMETRY_FILE_NAME}.`)).map((name2) => join(dir, name2));
1475
+ } catch {
1476
+ return [];
1477
+ }
1478
+ }
1479
+ async function readLegacyMeta(dir) {
1480
+ try {
1481
+ const raw = JSON.parse(await readFile(join(dir, "project.json"), "utf8"));
1482
+ if (!raw.path) return null;
1483
+ return { path: raw.path, firstSeenAt: parseTs(raw.firstSeenAt) ?? 0 };
1484
+ } catch {
1485
+ return null;
1486
+ }
1487
+ }
1488
+ function collect(lines, cutoff, out, stats, fallbackProjectId) {
1489
+ for (const line of lines) {
1490
+ let ev;
1491
+ try {
1492
+ ev = JSON.parse(line);
1493
+ } catch {
1494
+ stats.dropped += 1;
1495
+ continue;
1496
+ }
1497
+ const ts = parseTs(ev.ts);
1498
+ const event = typeof ev.event === "string" ? ev.event : "";
1499
+ const projectId = typeof ev.projectId === "string" ? ev.projectId : fallbackProjectId;
1500
+ if (ts === null || ts < cutoff || !event || !projectId || RETIRED_EVENTS.has(event)) {
1501
+ stats.dropped += 1;
1502
+ continue;
1503
+ }
1504
+ out.push({ ...ev, v: TELEMETRY_SCHEMA_VERSION, ts, event, projectId });
1505
+ }
1506
+ }
1507
+ function parseTs(value) {
1508
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
1509
+ if (typeof value !== "string") return null;
1510
+ const ms = Date.parse(value.includes("T") ? value : value.replace(" ", "T"));
1511
+ return Number.isNaN(ms) ? null : ms;
1512
+ }
1513
+ async function readLines(file) {
1514
+ try {
1515
+ return (await readFile(file, "utf8")).split("\n").filter((l) => l.trim());
1516
+ } catch {
1517
+ return [];
1518
+ }
1519
+ }
1520
+ async function readIndex(env) {
1521
+ try {
1522
+ return JSON.parse(await readFile(resolveProjectsIndexFile(env), "utf8"));
1523
+ } catch {
1524
+ return {};
1525
+ }
1526
+ }
1527
+ async function writeIndex(env, index) {
1528
+ if (Object.keys(index).length === 0) return;
1529
+ try {
1530
+ await writeFile(resolveProjectsIndexFile(env), `${JSON.stringify(index, null, 2)}
1531
+ `);
1532
+ } catch {
1533
+ }
1534
+ }
1535
+ async function writeAtomic(file, content) {
1536
+ const tmp = `${file}.tmp`;
1537
+ await mkdir(join(file, ".."), { recursive: true });
1538
+ await writeFile(tmp, content, "utf8");
1539
+ await rename(tmp, file);
1540
+ }
1202
1541
  function createSpecsRoutes(resolveProject) {
1203
1542
  const app = new Hono();
1204
1543
  const need = async (c) => {
@@ -1229,7 +1568,7 @@ function createSpecsRoutes(resolveProject) {
1229
1568
  const beforeIds = new Set((await p.store.list()).map((s) => s.id));
1230
1569
  const prompt = buildDraftPrompt(input.type, input.requirement, input.draftId);
1231
1570
  const { sessionId } = await p.sessions.createSession();
1232
- const handle = await p.sessions.send(sessionId, prompt);
1571
+ const handle = await p.sessions.send(sessionId, prompt, void 0, { trigger: "new-spec" });
1233
1572
  handle.onDone((finalSessionId) => {
1234
1573
  void bindDraftSessionToCreatedSpec(p, beforeIds, finalSessionId);
1235
1574
  });
@@ -1365,7 +1704,18 @@ function createSpecsRoutes(resolveProject) {
1365
1704
  "resume",
1366
1705
  await buildDebugRuntimeContext(p)
1367
1706
  ) : `${skillRef("yorz-spec")},然后处理 spec:${p.specsDirRelative}/${specId}/spec.md`;
1368
- const handle = await p.sessions.send(sessionId, prompt);
1707
+ const handle = await p.sessions.send(sessionId, prompt, void 0, {
1708
+ trigger: "append",
1709
+ specId
1710
+ });
1711
+ trackSpecStage({
1712
+ projectRoot: p.path,
1713
+ store: p.store,
1714
+ specId,
1715
+ handle,
1716
+ before: detail,
1717
+ trigger: "append"
1718
+ });
1369
1719
  return c.json({ ok: true, runId: handle.runId, sessionId });
1370
1720
  }
1371
1721
  return c.json({ ok: true });
@@ -1379,7 +1729,18 @@ function createSpecsRoutes(resolveProject) {
1379
1729
  const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
1380
1730
  const debugActive = await readDebugMdStatus(join(p.specsDir, specId)) === "debugging";
1381
1731
  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);
1732
+ const handle = await p.sessions.send(sessionId, prompt, void 0, {
1733
+ trigger: "run",
1734
+ specId
1735
+ });
1736
+ trackSpecStage({
1737
+ projectRoot: p.path,
1738
+ store: p.store,
1739
+ specId,
1740
+ handle,
1741
+ before: detail,
1742
+ trigger: "run"
1743
+ });
1383
1744
  return c.json({ runId: handle.runId, sessionId });
1384
1745
  });
1385
1746
  app.post("/projects/:projectId/specs/:id/explain", async (c) => {
@@ -1410,7 +1771,10 @@ ${text}
1410
1771
  """
1411
1772
  `;
1412
1773
  const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
1413
- const handle = await p.sessions.send(sessionId, prompt);
1774
+ const handle = await p.sessions.send(sessionId, prompt, void 0, {
1775
+ trigger: "explain",
1776
+ specId
1777
+ });
1414
1778
  return c.json({ runId: handle.runId, sessionId });
1415
1779
  });
1416
1780
  app.delete("/projects/:projectId/specs/:id", async (c) => {
@@ -2002,7 +2366,9 @@ function createSessionsRoutes(resolveProject) {
2002
2366
  }
2003
2367
  if (resolved.builtin === "yorz-debug") void cleanupExpiredChatDebugFiles(p.path).catch(() => {
2004
2368
  });
2005
- const handle = await p.sessions.send(c.req.param("sid"), finalPrompt, prompt);
2369
+ const handle = await p.sessions.send(c.req.param("sid"), finalPrompt, prompt, {
2370
+ trigger: "chat"
2371
+ });
2006
2372
  return c.json({ runId: handle.runId, sessionId: handle.sessionId }, 202);
2007
2373
  });
2008
2374
  app.post("/projects/:projectId/sessions/:sid/abort", async (c) => {
@@ -2198,6 +2564,119 @@ async function discard(cwd, opts) {
2198
2564
  await runGit(cwd, ["clean", "-fd", "--", r.path]);
2199
2565
  }
2200
2566
  }
2567
+ const MAX_PATCH_BYTES = 512 * 1024;
2568
+ const MAX_PATCH_LINES = 3e3;
2569
+ function truncatePatch(patch) {
2570
+ let text = patch;
2571
+ let truncated = false;
2572
+ if (Buffer.byteLength(text, "utf8") > MAX_PATCH_BYTES) {
2573
+ text = text.slice(0, MAX_PATCH_BYTES);
2574
+ truncated = true;
2575
+ }
2576
+ const lines = text.split("\n");
2577
+ if (lines.length > MAX_PATCH_LINES) {
2578
+ text = lines.slice(0, MAX_PATCH_LINES).join("\n");
2579
+ truncated = true;
2580
+ }
2581
+ return { patch: text, truncated };
2582
+ }
2583
+ async function untrackedDiff(cwd, path) {
2584
+ let buf;
2585
+ try {
2586
+ buf = await readFile(resolve(cwd, path));
2587
+ } catch {
2588
+ return { path, patch: "", binary: false, truncated: false };
2589
+ }
2590
+ if (buf.subarray(0, 8e3).includes(0)) {
2591
+ return { path, patch: "", binary: true, truncated: false };
2592
+ }
2593
+ const text = buf.toString("utf8");
2594
+ const lines = text.length === 0 ? [] : text.replace(/\n$/, "").split("\n");
2595
+ const body = lines.map((l) => `+${l}`).join("\n");
2596
+ const header = `--- /dev/null
2597
+ +++ b/${path}
2598
+ @@ -0,0 +1,${lines.length} @@
2599
+ `;
2600
+ const { patch, truncated } = truncatePatch(lines.length ? `${header}${body}` : header);
2601
+ return { path, patch, binary: false, truncated };
2602
+ }
2603
+ async function fileDiff(cwd, path) {
2604
+ assertSafeRelativePath(cwd, path);
2605
+ const change = (await listChanges(cwd)).find((c) => c.path === path);
2606
+ if (change?.status === "??") return untrackedDiff(cwd, path);
2607
+ const pathArgs = change?.renamedFrom ? [change.renamedFrom, path] : [path];
2608
+ const res = await runGitRaw(cwd, ["diff", "HEAD", "--", ...pathArgs]);
2609
+ if (res.code !== 0 && !res.stdout) {
2610
+ throw new GitError("git_failed", res.stderr.trim() || "git diff failed", res.stderr);
2611
+ }
2612
+ const raw = res.stdout;
2613
+ if (!raw.includes("@@") && /^Binary files .* differ$/m.test(raw)) {
2614
+ return { path, patch: "", binary: true, truncated: false };
2615
+ }
2616
+ const { patch, truncated } = truncatePatch(raw);
2617
+ return { path, patch, binary: false, truncated };
2618
+ }
2619
+ async function currentBranch(cwd) {
2620
+ const { stdout } = await runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
2621
+ const branch = stdout.trim();
2622
+ if (!branch || branch === "HEAD") {
2623
+ throw new GitError("detached_head", "cannot operate from a detached HEAD");
2624
+ }
2625
+ return branch;
2626
+ }
2627
+ async function listBranches(cwd) {
2628
+ const [current, branchesResult] = await Promise.all([
2629
+ currentBranch(cwd),
2630
+ runGit(cwd, [
2631
+ "for-each-ref",
2632
+ "--sort=-committerdate",
2633
+ "--format=%(refname:short)",
2634
+ "refs/heads"
2635
+ ])
2636
+ ]);
2637
+ const branches = branchesResult.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
2638
+ return { current, branches };
2639
+ }
2640
+ async function checkoutBranch(cwd, branch) {
2641
+ const target = branch?.trim() ?? "";
2642
+ if (!target) throw new GitError("invalid_branch", "branch must not be empty");
2643
+ const state = await listBranches(cwd);
2644
+ if (!state.branches.includes(target)) {
2645
+ throw new GitError("invalid_branch", `unknown local branch: ${target}`);
2646
+ }
2647
+ if (state.current === target) return { current: state.current };
2648
+ const res = await runGitRaw(cwd, ["checkout", target]);
2649
+ if (res.code !== 0) {
2650
+ throw new GitError("checkout_failed", res.stderr.trim() || "git checkout failed", res.stderr);
2651
+ }
2652
+ return { current: await currentBranch(cwd) };
2653
+ }
2654
+ async function push(cwd) {
2655
+ const branch = await currentBranch(cwd);
2656
+ const upstream = await runGitRaw(cwd, [
2657
+ "rev-parse",
2658
+ "--abbrev-ref",
2659
+ "--symbolic-full-name",
2660
+ "@{u}"
2661
+ ]);
2662
+ const hasUpstream = upstream.code === 0 && upstream.stdout.trim().length > 0;
2663
+ const args = hasUpstream ? ["push"] : ["push", "--set-upstream", "origin", branch];
2664
+ const res = await runGitRaw(cwd, args);
2665
+ if (res.code !== 0) {
2666
+ throw new GitError("push_failed", res.stderr.trim() || "git push failed", res.stderr);
2667
+ }
2668
+ return { branch, createdUpstream: !hasUpstream };
2669
+ }
2670
+ async function pull(cwd) {
2671
+ const branch = await currentBranch(cwd);
2672
+ const before = (await runGit(cwd, ["rev-parse", "HEAD"])).stdout.trim();
2673
+ const res = await runGitRaw(cwd, ["pull", "--ff-only"]);
2674
+ if (res.code !== 0) {
2675
+ throw new GitError("pull_failed", res.stderr.trim() || "git pull failed", res.stderr);
2676
+ }
2677
+ const after = (await runGit(cwd, ["rev-parse", "HEAD"])).stdout.trim();
2678
+ return { branch, updated: before !== after };
2679
+ }
2201
2680
  async function stash(cwd, opts) {
2202
2681
  if (!Array.isArray(opts.paths) || opts.paths.length === 0) {
2203
2682
  throw new GitError("no_paths", "stash requires at least one path");
@@ -2238,7 +2717,18 @@ function createSpecReviewRoutes(resolveProject) {
2238
2717
  const specRel = `${p.specsDirRelative}/${specId}/spec.md`;
2239
2718
  const prompt = buildGitOpsPrompt(action, specId, specRel);
2240
2719
  const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
2241
- const handle = await p.sessions.send(sessionId, prompt);
2720
+ const handle = await p.sessions.send(sessionId, prompt, void 0, {
2721
+ trigger: "git-ops",
2722
+ specId
2723
+ });
2724
+ trackSpecStage({
2725
+ projectRoot: p.path,
2726
+ store: p.store,
2727
+ specId,
2728
+ handle,
2729
+ before: detail,
2730
+ trigger: "git-ops"
2731
+ });
2242
2732
  return c.json({ runId: handle.runId, sessionId });
2243
2733
  });
2244
2734
  app.get("/projects/:projectId/specs/:id/debug", async (c) => {
@@ -2256,21 +2746,7 @@ function createSpecReviewRoutes(resolveProject) {
2256
2746
  return c.json({ exists: false, text: "" });
2257
2747
  }
2258
2748
  });
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) => {
2749
+ app.post("/projects/:projectId/specs/:id/stash", async (c) => {
2274
2750
  const p = await need(c);
2275
2751
  if (p instanceof Response) return p;
2276
2752
  const specId = c.req.param("id");
@@ -2285,30 +2761,122 @@ function createSpecReviewRoutes(resolveProject) {
2285
2761
  const obj = body && typeof body === "object" ? body : {};
2286
2762
  const message = typeof obj.message === "string" ? obj.message.trim() : "";
2287
2763
  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
2764
  if (paths.length === 0) return c.json({ error: "paths must not be empty" }, 400);
2290
2765
  try {
2291
- const result = await commit(p.path, { message, paths });
2292
- return c.json(result);
2766
+ await stash(p.path, { message: message || `yorz:${specId}`, paths });
2767
+ return c.json({ ok: true });
2768
+ } catch (err) {
2769
+ if (err instanceof GitError) return c.json({ error: err.message }, 400);
2770
+ throw err;
2771
+ }
2772
+ });
2773
+ return app;
2774
+ }
2775
+ function buildGitOpsPrompt(action, specId, specRel) {
2776
+ const base = `${skillRef("yorz-git-ops")},然后执行 git 操作:
2777
+ - spec 文档:${specRel}
2778
+ - 请依据 spec 文档与 git status/diff 自行判断本次 spec 关联的变更文件
2779
+ - spec-id:${specId}
2780
+ `;
2781
+ switch (action) {
2782
+ case "commit":
2783
+ return base + `- 动作:git-commit。请基于 spec 文档与 git status/diff,由你自主判断本次 spec 相关的变更文件,执行 \`git add\` + \`git commit\`;commit message 由你生成,不带 scope;禁止 \`git push\` 与 \`git reset --hard\`。
2784
+ `;
2785
+ case "discard":
2786
+ return base + `- 动作:git-discard。请使用 \`git restore --staged --worktree -- <paths>\` 与 \`git clean -fd -- <paths>\` 丢弃 spec 相关的未提交变更;对 untracked 新文件应先在终端输出列表再处理;不要预先 stash 备份;禁止 \`git reset --hard\` 与 \`git push\`。
2787
+ `;
2788
+ case "stash":
2789
+ return base + `- 动作:git-stash。请使用 \`git stash push -m "yorz:${specId}" -- <paths>\` 暂存 spec 相关变更文件;禁止 \`git push\`。
2790
+ `;
2791
+ }
2792
+ }
2793
+ function createGitRoutes(resolveProject) {
2794
+ const app = new Hono();
2795
+ const need = async (c) => {
2796
+ const id = c.req.param("projectId") ?? "";
2797
+ if (!id) return c.json({ error: "projectId required" }, 400);
2798
+ const project = await resolveProject(id);
2799
+ if (!project) return c.json({ error: "project not found" }, 404);
2800
+ return project;
2801
+ };
2802
+ const readBody = async (c) => {
2803
+ try {
2804
+ const body = await c.req.json();
2805
+ return body && typeof body === "object" ? body : {};
2806
+ } catch {
2807
+ return null;
2808
+ }
2809
+ };
2810
+ const toPaths = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
2811
+ app.get("/projects/:projectId/git/changes", async (c) => {
2812
+ const p = await need(c);
2813
+ if (p instanceof Response) return p;
2814
+ try {
2815
+ return c.json({ changes: await listChanges(p.path) });
2293
2816
  } catch (err) {
2294
2817
  if (err instanceof GitError) return c.json({ error: err.message }, 400);
2295
2818
  throw err;
2296
2819
  }
2297
2820
  });
2298
- app.post("/projects/:projectId/specs/:id/discard", async (c) => {
2821
+ app.get("/projects/:projectId/git/diff", async (c) => {
2299
2822
  const p = await need(c);
2300
2823
  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;
2824
+ const path = c.req.query("path") ?? "";
2825
+ if (!path) return c.json({ error: "path required" }, 400);
2305
2826
  try {
2306
- body = await c.req.json();
2307
- } catch {
2308
- return c.json({ error: "invalid JSON body" }, 400);
2827
+ return c.json(await fileDiff(p.path, path));
2828
+ } catch (err) {
2829
+ if (err instanceof GitError) return c.json({ error: err.message }, 400);
2830
+ throw err;
2309
2831
  }
2310
- const obj = body && typeof body === "object" ? body : {};
2311
- const paths = Array.isArray(obj.paths) ? obj.paths.filter((v) => typeof v === "string") : [];
2832
+ });
2833
+ app.get("/projects/:projectId/git/branches", async (c) => {
2834
+ const p = await need(c);
2835
+ if (p instanceof Response) return p;
2836
+ try {
2837
+ return c.json(await listBranches(p.path));
2838
+ } catch (err) {
2839
+ if (err instanceof GitError) return c.json({ error: err.message }, 400);
2840
+ throw err;
2841
+ }
2842
+ });
2843
+ app.post("/projects/:projectId/git/checkout", async (c) => {
2844
+ const p = await need(c);
2845
+ if (p instanceof Response) return p;
2846
+ const body = await readBody(c);
2847
+ if (!body) return c.json({ error: "invalid JSON body" }, 400);
2848
+ const branch = typeof body.branch === "string" ? body.branch.trim() : "";
2849
+ if (!branch) return c.json({ error: "branch must not be empty" }, 400);
2850
+ try {
2851
+ const result = await checkoutBranch(p.path, branch);
2852
+ return c.json({ ok: true, ...result });
2853
+ } catch (err) {
2854
+ if (err instanceof GitError) return c.json({ error: err.message }, 400);
2855
+ throw err;
2856
+ }
2857
+ });
2858
+ app.post("/projects/:projectId/git/commit", async (c) => {
2859
+ const p = await need(c);
2860
+ if (p instanceof Response) return p;
2861
+ const body = await readBody(c);
2862
+ if (!body) return c.json({ error: "invalid JSON body" }, 400);
2863
+ const message = typeof body.message === "string" ? body.message.trim() : "";
2864
+ const paths = toPaths(body.paths);
2865
+ if (!message) return c.json({ error: "message must not be empty" }, 400);
2866
+ if (paths.length === 0) return c.json({ error: "paths must not be empty" }, 400);
2867
+ try {
2868
+ return c.json(await commit(p.path, { message, paths }));
2869
+ } catch (err) {
2870
+ if (err instanceof GitError) return c.json({ error: err.message }, 400);
2871
+ throw err;
2872
+ }
2873
+ });
2874
+ app.post("/projects/:projectId/git/discard", async (c) => {
2875
+ const p = await need(c);
2876
+ if (p instanceof Response) return p;
2877
+ const body = await readBody(c);
2878
+ if (!body) return c.json({ error: "invalid JSON body" }, 400);
2879
+ const paths = toPaths(body.paths);
2312
2880
  if (paths.length === 0) return c.json({ error: "paths must not be empty" }, 400);
2313
2881
  try {
2314
2882
  await discard(p.path, { paths });
@@ -2318,25 +2886,23 @@ function createSpecReviewRoutes(resolveProject) {
2318
2886
  throw err;
2319
2887
  }
2320
2888
  });
2321
- app.post("/projects/:projectId/specs/:id/stash", async (c) => {
2889
+ app.post("/projects/:projectId/git/push", async (c) => {
2322
2890
  const p = await need(c);
2323
2891
  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
2892
  try {
2329
- body = await c.req.json();
2330
- } catch {
2331
- return c.json({ error: "invalid JSON body" }, 400);
2893
+ const result = await push(p.path);
2894
+ return c.json({ ok: true, ...result });
2895
+ } catch (err) {
2896
+ if (err instanceof GitError) return c.json({ error: err.message }, 400);
2897
+ throw err;
2332
2898
  }
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);
2899
+ });
2900
+ app.post("/projects/:projectId/git/pull", async (c) => {
2901
+ const p = await need(c);
2902
+ if (p instanceof Response) return p;
2337
2903
  try {
2338
- await stash(p.path, { message: message || `yorz:${specId}`, paths });
2339
- return c.json({ ok: true });
2904
+ const result = await pull(p.path);
2905
+ return c.json({ ok: true, ...result });
2340
2906
  } catch (err) {
2341
2907
  if (err instanceof GitError) return c.json({ error: err.message }, 400);
2342
2908
  throw err;
@@ -2344,24 +2910,6 @@ function createSpecReviewRoutes(resolveProject) {
2344
2910
  });
2345
2911
  return app;
2346
2912
  }
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
2913
  const sseLog = () => getLogger().child("sse");
2366
2914
  const HEARTBEAT_INTERVAL_MS = 5e3;
2367
2915
  function attachHeartbeat(stream, intervalMs = HEARTBEAT_INTERVAL_MS) {
@@ -2553,9 +3101,8 @@ class EventsHub {
2553
3101
  if (rest === "commands") return this.attachCommandRuns(s, topic, project);
2554
3102
  const cm = /^command:(.+)$/.exec(rest);
2555
3103
  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);
3104
+ if (rest === "changes") return this.attachProjectChanges(s, topic, project);
3105
+ let sm = /^spec:([^:]+)$/.exec(rest);
2559
3106
  if (sm) return this.attachSpec(s, topic, project, sm[1]);
2560
3107
  sm = /^session:(.+)$/.exec(rest);
2561
3108
  if (sm) return this.attachSession(s, topic, project, sm[1]);
@@ -2593,9 +3140,12 @@ class EventsHub {
2593
3140
  this.emit(s, topic, "updated", { type: kind, mtime });
2594
3141
  });
2595
3142
  }
2596
- async attachSpecChanges(s, topic, project, specId) {
2597
- const exists = await project.store.read(specId);
2598
- if (!exists) throw new Error("spec not found");
3143
+ /**
3144
+ * Git changes are repository-wide: the watcher is keyed on the project path,
3145
+ * so this topic carries no spec id and both the standalone Git page and the
3146
+ * spec Review page share one subscription.
3147
+ */
3148
+ attachProjectChanges(s, topic, project) {
2599
3149
  this.emit(s, topic, "ready", {});
2600
3150
  const { current, unsubscribe } = subscribeGitChanges(project.path, (changes) => {
2601
3151
  this.emit(s, topic, "changes-updated", { changes });
@@ -3986,6 +4536,15 @@ class CommandManager {
3986
4536
  this.notifyRuns();
3987
4537
  this.notifyRun(next);
3988
4538
  log$3().info("command ended", { runId, status, exitCode, signal });
4539
+ getTelemetry(this.projectPath).record("cmd.exec", {
4540
+ commandId: next.commandId,
4541
+ name: next.name,
4542
+ runId,
4543
+ status,
4544
+ exitCode: exitCode ?? void 0,
4545
+ signal: signal ?? void 0,
4546
+ durMs: (next.endedAt ?? Date.now()) - next.startedAt
4547
+ });
3989
4548
  }
3990
4549
  notifyRuns() {
3991
4550
  if (this.runsSubscribers.size === 0) return;
@@ -4934,7 +5493,9 @@ function createApp(opts) {
4934
5493
  const { sessionId } = await main.sessions.ensureSessionForSpec(specId);
4935
5494
  void main.sessions.send(
4936
5495
  sessionId,
4937
- `${skillRef("yorz-spec")},然后处理 spec:${main.specsDirRelative}/${specId}/spec.md`
5496
+ `${skillRef("yorz-spec")},然后处理 spec:${main.specsDirRelative}/${specId}/spec.md`,
5497
+ void 0,
5498
+ { trigger: "conflict", specId }
4938
5499
  );
4939
5500
  }
4940
5501
  });
@@ -4947,6 +5508,7 @@ function createApp(opts) {
4947
5508
  api.route("/", createSpecsRoutes(resolveProject));
4948
5509
  api.route("/", createSessionsRoutes(resolveProject));
4949
5510
  api.route("/", createSpecReviewRoutes(resolveProject));
5511
+ api.route("/", createGitRoutes(resolveProject));
4950
5512
  api.route("/", createSpecDraftsRoutes(resolveProject));
4951
5513
  api.route("/", createWorktreeRoutes(opts.registry, worktreeManager));
4952
5514
  api.route("/", createProjectFilesRoutes(resolveProject));
@@ -5586,6 +6148,29 @@ function usageWindows(usage) {
5586
6148
  }
5587
6149
  return out;
5588
6150
  }
6151
+ function num$1(value) {
6152
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
6153
+ }
6154
+ function isRecord(value) {
6155
+ return !!value && typeof value === "object" && !Array.isArray(value);
6156
+ }
6157
+ function withCost(usage, costUsd) {
6158
+ if (costUsd === void 0) return usage;
6159
+ return { ...usage ?? {}, costUsd };
6160
+ }
6161
+ function primaryModel(modelUsage) {
6162
+ if (!isRecord(modelUsage)) return void 0;
6163
+ let best;
6164
+ let bestOutput = -1;
6165
+ for (const [model, raw] of Object.entries(modelUsage)) {
6166
+ const output = isRecord(raw) ? num$1(raw.outputTokens) ?? 0 : 0;
6167
+ if (output > bestOutput) {
6168
+ bestOutput = output;
6169
+ best = model;
6170
+ }
6171
+ }
6172
+ return best;
6173
+ }
5589
6174
  class ClaudeSession {
5590
6175
  constructor(id, cwd, isNew) {
5591
6176
  this.id = id;
@@ -5612,6 +6197,7 @@ class ClaudeSession {
5612
6197
  try {
5613
6198
  const q = query({ prompt, options });
5614
6199
  let usage;
6200
+ let metrics;
5615
6201
  let completed = false;
5616
6202
  for await (const msg of q) {
5617
6203
  const m = msg;
@@ -5636,12 +6222,32 @@ class ClaudeSession {
5636
6222
  }
5637
6223
  } else if (m.type === "result") {
5638
6224
  usage = m.usage;
6225
+ metrics = {
6226
+ usage: withCost(normalizeUsage("claude", m.usage), num$1(m.total_cost_usd)),
6227
+ modelUsage: isRecord(m.modelUsage) ? m.modelUsage : void 0,
6228
+ numTurns: num$1(m.num_turns),
6229
+ stopReason: typeof m.stop_reason === "string" ? m.stop_reason : void 0,
6230
+ model: primaryModel(m.modelUsage),
6231
+ durationMs: num$1(m.duration_ms),
6232
+ apiDurationMs: num$1(m.duration_api_ms)
6233
+ };
6234
+ } else if (m.type === "system" && m.subtype === "compact_boundary") {
6235
+ const meta = m.compact_metadata ?? {};
6236
+ yield {
6237
+ type: "compact",
6238
+ metrics: {
6239
+ trigger: meta.trigger === "manual" || meta.trigger === "auto" ? meta.trigger : void 0,
6240
+ preTokens: num$1(meta.pre_tokens),
6241
+ postTokens: num$1(meta.post_tokens),
6242
+ durationMs: num$1(meta.duration_ms)
6243
+ }
6244
+ };
5639
6245
  } else if (m.type === "system" && m.subtype === "session_state_changed" && m.state === "idle") {
5640
6246
  completed = true;
5641
- yield { type: "turn-completed", usage };
6247
+ yield { type: "turn-completed", usage, metrics };
5642
6248
  }
5643
6249
  }
5644
- if (!completed) yield { type: "turn-completed", usage };
6250
+ if (!completed) yield { type: "turn-completed", usage, metrics };
5645
6251
  } catch (err) {
5646
6252
  if (ctrl.signal.aborted) return;
5647
6253
  yield { type: "error", message: err instanceof Error ? err.message : String(err) };
@@ -5924,7 +6530,11 @@ class CodexSession {
5924
6530
  yield { type: "error", message: item.message };
5925
6531
  }
5926
6532
  } else if (ev.type === "turn.completed") {
5927
- yield { type: "turn-completed", usage: ev.usage };
6533
+ yield {
6534
+ type: "turn-completed",
6535
+ usage: ev.usage,
6536
+ metrics: { usage: normalizeUsage("codex", ev.usage) }
6537
+ };
5928
6538
  } else if (ev.type === "turn.failed") {
5929
6539
  yield { type: "error", message: ev.error.message };
5930
6540
  } else if (ev.type === "error") {
@@ -6310,7 +6920,14 @@ class OpenCodeSession {
6310
6920
  for (const part of textParts(data.parts)) {
6311
6921
  if (part.type === "text") yield { type: "text", delta: part.text };
6312
6922
  }
6313
- yield { type: "turn-completed", usage: data.info.tokens };
6923
+ yield {
6924
+ type: "turn-completed",
6925
+ usage: data.info.tokens,
6926
+ metrics: {
6927
+ usage: normalizeUsage("opencode", data.info.tokens),
6928
+ model: typeof data.info.modelID === "string" ? data.info.modelID : void 0
6929
+ }
6930
+ };
6314
6931
  } catch (err) {
6315
6932
  if (this.aborted) return;
6316
6933
  yield { type: "error", message: err instanceof Error ? err.message : String(err) };
@@ -6678,9 +7295,11 @@ class SessionManager {
6678
7295
  * text while sending an expanded prompt to the Agent. Without it, an injected
6679
7296
  * hidden prompt would become the title.
6680
7297
  */
6681
- async send(sid, prompt, titleSource) {
7298
+ async send(sid, prompt, titleSource, meta = {}) {
6682
7299
  const runId = randomUUID();
6683
7300
  const emitter = this.emitterFor(sid);
7301
+ const telemetry = getTelemetry(this.cwd);
7302
+ const attribution = { specId: meta.specId, trigger: meta.trigger };
6684
7303
  let currentSid = sid;
6685
7304
  const startedAt = Date.now();
6686
7305
  await this.maybeUpdateTitleFromPrompt(currentSid, titleSource ?? prompt).catch(() => {
@@ -6689,18 +7308,57 @@ class SessionManager {
6689
7308
  });
6690
7309
  this.setRunning(sid, true);
6691
7310
  agentLog().info("dispatch start", { sessionId: sid, runId, promptLength: prompt.length });
7311
+ telemetry.record("agent.dispatch", {
7312
+ ...attribution,
7313
+ phase: "start",
7314
+ sessionId: sid,
7315
+ traceId: runId,
7316
+ promptLength: prompt.length
7317
+ });
7318
+ let agentKind;
7319
+ let dispatchError;
6692
7320
  const task = (async () => {
6693
7321
  try {
6694
7322
  const ls = await this.ensureLive(sid);
7323
+ agentKind = ls.kind;
6695
7324
  for await (const ev of ls.session.send(prompt)) {
6696
7325
  if (ev.type === "session-started" && ev.sessionId !== currentSid) {
6697
7326
  const oldSid = currentSid;
6698
7327
  currentSid = ev.sessionId;
6699
7328
  await this.reconcile(oldSid, ev.sessionId);
7329
+ } else if (ev.type === "turn-completed") {
7330
+ const m = ev.metrics ?? {};
7331
+ telemetry.record("agent.turn", {
7332
+ ...attribution,
7333
+ sessionId: currentSid,
7334
+ traceId: runId,
7335
+ agentKind,
7336
+ usage: m.usage,
7337
+ modelUsage: m.modelUsage,
7338
+ numTurns: m.numTurns,
7339
+ stopReason: m.stopReason,
7340
+ model: m.model,
7341
+ durMs: m.durationMs,
7342
+ apiDurMs: m.apiDurationMs
7343
+ });
7344
+ } else if (ev.type === "compact") {
7345
+ telemetry.record("agent.compact", {
7346
+ ...attribution,
7347
+ sessionId: currentSid,
7348
+ traceId: runId,
7349
+ agentKind,
7350
+ // Named apart from the dispatch `trigger` so both survive on the
7351
+ // same line: which action started the run, and what compacted it.
7352
+ compactTrigger: ev.metrics.trigger,
7353
+ preTokens: ev.metrics.preTokens,
7354
+ postTokens: ev.metrics.postTokens,
7355
+ durMs: ev.metrics.durationMs
7356
+ });
6700
7357
  }
6701
7358
  emitter.emit("event", ev);
6702
7359
  }
6703
7360
  } catch (err) {
7361
+ dispatchError = err instanceof Error ? err.message : String(err);
6704
7362
  agentLog().error("dispatch failed", {
6705
7363
  sessionId: currentSid,
6706
7364
  runId,
@@ -6718,6 +7376,17 @@ class SessionManager {
6718
7376
  runId,
6719
7377
  durationMs: Date.now() - startedAt
6720
7378
  });
7379
+ telemetry.record("agent.dispatch", {
7380
+ ...attribution,
7381
+ phase: "end",
7382
+ sessionId: currentSid,
7383
+ traceId: runId,
7384
+ agentKind,
7385
+ promptLength: prompt.length,
7386
+ durMs: Date.now() - startedAt,
7387
+ ok: dispatchError === void 0,
7388
+ errorMessage: dispatchError
7389
+ });
6721
7390
  await this.store.touch(currentSid).catch(() => {
6722
7391
  });
6723
7392
  this.setRunning(currentSid, false);
@@ -7271,6 +7940,12 @@ async function start(opts = {}) {
7271
7940
  } catch {
7272
7941
  }
7273
7942
  }
7943
+ try {
7944
+ const stats = await initTelemetryStore();
7945
+ log$1().info("telemetry store ready", { ...stats });
7946
+ } catch (err) {
7947
+ log$1().warn("telemetry housekeeping failed", { err });
7948
+ }
7274
7949
  const projects = await registry.list();
7275
7950
  const systemNotifications = new SystemNotificationCenter();
7276
7951
  systemNotifications.startVersionChecks();
@@ -8909,6 +9584,7 @@ async function lintFile(path, opts = {}) {
8909
9584
  }
8910
9585
  [...new Set([...SPEC_RULES, ...REVIEW_RULES].map((r) => r.id))];
8911
9586
  async function runLint(opts) {
9587
+ const startedAt = Date.now();
8912
9588
  const cwd = resolve(opts.cwd);
8913
9589
  let targets = [];
8914
9590
  if (opts.all) {
@@ -8978,8 +9654,219 @@ async function runLint(opts) {
8978
9654
  Total: ${errorCount} error(s), ${warnCount} warn(s) across ${reports.length} file(s).
8979
9655
  `);
8980
9656
  }
9657
+ await recordLintRun(cwd, {
9658
+ fileCount: targets.length,
9659
+ errorCount,
9660
+ warnCount,
9661
+ exitCode,
9662
+ durMs: Date.now() - startedAt
9663
+ });
8981
9664
  return { reports, errorCount, warnCount, exitCode };
8982
9665
  }
9666
+ async function recordLintRun(cwd, payload) {
9667
+ const root = findProjectRoot(cwd);
9668
+ if (!root) return;
9669
+ const telemetry = getTelemetry(root);
9670
+ telemetry.record("lint.run", payload);
9671
+ await telemetry.flush();
9672
+ }
9673
+ const UNATTRIBUTED = "(no spec)";
9674
+ async function runMetrics(opts) {
9675
+ const file = resolveTelemetryFile();
9676
+ if (!existsSync(file)) {
9677
+ process.stderr.write("no telemetry found — run a dispatch first\n");
9678
+ return { exitCode: 1, summary: emptySummary() };
9679
+ }
9680
+ const filter = resolveProjectFilter(opts);
9681
+ const summary = emptySummary();
9682
+ summary.files.push(file);
9683
+ await consumeFile(file, opts, filter, summary);
9684
+ if (summary.lines === 0) {
9685
+ process.stderr.write(
9686
+ filter ? `no telemetry for project ${filter} — pass --project <id|path> / --all
9687
+ ` : "no telemetry matched the given filters\n"
9688
+ );
9689
+ return { exitCode: 1, summary };
9690
+ }
9691
+ summary.projects = await projectLabels(summary.projects);
9692
+ summary.specs.sort((a, b) => b.costUsd - a.costUsd || b.turns - a.turns);
9693
+ if (opts.format === "json") {
9694
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}
9695
+ `);
9696
+ } else {
9697
+ process.stdout.write(renderText(summary));
9698
+ }
9699
+ return { exitCode: 0, summary };
9700
+ }
9701
+ function resolveProjectFilter(opts) {
9702
+ if (opts.all) return null;
9703
+ if (opts.project) {
9704
+ return isAbsolute(opts.project) ? generateProjectId(resolve(opts.project)) : opts.project;
9705
+ }
9706
+ const root = findProjectRoot(opts.cwd);
9707
+ return root ? generateProjectId(root) : null;
9708
+ }
9709
+ async function projectLabels(ids) {
9710
+ let index = {};
9711
+ try {
9712
+ index = JSON.parse(await readFile(resolveProjectsIndexFile(), "utf8"));
9713
+ } catch {
9714
+ }
9715
+ return ids.map((id) => index[id]?.path ? `${id} (${index[id].path})` : id);
9716
+ }
9717
+ function sinceMs(since) {
9718
+ if (!since) return null;
9719
+ const ms = Date.parse(`${since}T00:00:00`);
9720
+ return Number.isNaN(ms) ? null : ms;
9721
+ }
9722
+ async function consumeFile(file, opts, filter, out) {
9723
+ let raw;
9724
+ try {
9725
+ raw = await readFile(file, "utf8");
9726
+ } catch {
9727
+ return;
9728
+ }
9729
+ const from = sinceMs(opts.since);
9730
+ const seenProjects = /* @__PURE__ */ new Set();
9731
+ for (const line of raw.split("\n")) {
9732
+ if (!line.trim()) continue;
9733
+ let ev;
9734
+ try {
9735
+ ev = JSON.parse(line);
9736
+ } catch {
9737
+ out.skipped += 1;
9738
+ continue;
9739
+ }
9740
+ const projectId = typeof ev.projectId === "string" ? ev.projectId : "";
9741
+ if (filter && projectId !== filter) continue;
9742
+ const ts = parseTs(ev.ts);
9743
+ if (from !== null && (ts === null || ts < from)) continue;
9744
+ if (projectId && !seenProjects.has(projectId)) {
9745
+ seenProjects.add(projectId);
9746
+ out.projects.push(projectId);
9747
+ }
9748
+ out.lines += 1;
9749
+ const event = typeof ev.event === "string" ? ev.event : "unknown";
9750
+ out.eventCounts[event] = (out.eventCounts[event] ?? 0) + 1;
9751
+ applyEvent(ev, event, out);
9752
+ }
9753
+ }
9754
+ function applyEvent(ev, event, out) {
9755
+ if (!event.startsWith("agent.")) return;
9756
+ const bucket = bucketFor(out, typeof ev.specId === "string" ? ev.specId : UNATTRIBUTED);
9757
+ if (event === "agent.dispatch") {
9758
+ if (ev.phase !== "end") return;
9759
+ bucket.dispatches += 1;
9760
+ out.totals.dispatches += 1;
9761
+ if (ev.ok === false) {
9762
+ bucket.failures += 1;
9763
+ out.totals.failures += 1;
9764
+ }
9765
+ return;
9766
+ }
9767
+ if (event === "agent.compact") {
9768
+ bucket.compactions += 1;
9769
+ out.totals.compactions += 1;
9770
+ return;
9771
+ }
9772
+ if (event !== "agent.turn") return;
9773
+ bucket.turns += 1;
9774
+ out.totals.turns += 1;
9775
+ const durMs = num(ev.durMs);
9776
+ bucket.agentDurMs += durMs;
9777
+ out.totals.agentDurMs += durMs;
9778
+ const usage = ev.usage ?? {};
9779
+ for (const key of [
9780
+ "inputTokens",
9781
+ "cacheReadTokens",
9782
+ "cacheCreateTokens",
9783
+ "outputTokens"
9784
+ ]) {
9785
+ bucket[key] += num(usage[key]);
9786
+ out.totals[key] += num(usage[key]);
9787
+ }
9788
+ bucket.costUsd += num(usage.costUsd);
9789
+ out.totals.costUsd += num(usage.costUsd);
9790
+ }
9791
+ function bucketFor(out, specId) {
9792
+ let bucket = out.specs.find((s) => s.specId === specId);
9793
+ if (!bucket) {
9794
+ bucket = {
9795
+ specId,
9796
+ dispatches: 0,
9797
+ turns: 0,
9798
+ compactions: 0,
9799
+ inputTokens: 0,
9800
+ cacheReadTokens: 0,
9801
+ cacheCreateTokens: 0,
9802
+ outputTokens: 0,
9803
+ costUsd: 0,
9804
+ agentDurMs: 0,
9805
+ failures: 0,
9806
+ stages: []
9807
+ };
9808
+ out.specs.push(bucket);
9809
+ }
9810
+ return bucket;
9811
+ }
9812
+ function num(value) {
9813
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
9814
+ }
9815
+ function emptySummary() {
9816
+ return {
9817
+ projects: [],
9818
+ files: [],
9819
+ lines: 0,
9820
+ skipped: 0,
9821
+ eventCounts: {},
9822
+ totals: {
9823
+ dispatches: 0,
9824
+ turns: 0,
9825
+ compactions: 0,
9826
+ inputTokens: 0,
9827
+ cacheReadTokens: 0,
9828
+ cacheCreateTokens: 0,
9829
+ outputTokens: 0,
9830
+ costUsd: 0,
9831
+ agentDurMs: 0,
9832
+ failures: 0
9833
+ },
9834
+ specs: []
9835
+ };
9836
+ }
9837
+ const SPEC_ROW_LIMIT = 20;
9838
+ function renderText(s) {
9839
+ const lines = [];
9840
+ lines.push(`projects: ${s.projects.join(", ") || "(none)"}`);
9841
+ lines.push(`events: ${s.lines}${s.skipped ? ` (${s.skipped} unparseable line(s))` : ""}`);
9842
+ const kinds = Object.entries(s.eventCounts).sort((a, b) => b[1] - a[1]);
9843
+ if (kinds.length) {
9844
+ lines.push(`by kind: ${kinds.map(([k, n]) => `${k}=${n}`).join(" ")}`);
9845
+ }
9846
+ const t = s.totals;
9847
+ lines.push("");
9848
+ lines.push(
9849
+ `total: ${t.dispatches} dispatch(es), ${t.turns} turn(s), ${t.failures} failure(s), ${t.compactions} compaction(s)`
9850
+ );
9851
+ lines.push(
9852
+ `tokens: in=${t.inputTokens} cacheRead=${t.cacheReadTokens} cacheWrite=${t.cacheCreateTokens} out=${t.outputTokens}`
9853
+ );
9854
+ lines.push(`cost: $${t.costUsd.toFixed(4)} agent time: ${(t.agentDurMs / 1e3).toFixed(1)}s`);
9855
+ if (s.specs.length) {
9856
+ lines.push("");
9857
+ lines.push("per spec (by cost):");
9858
+ for (const spec of s.specs.slice(0, SPEC_ROW_LIMIT)) {
9859
+ lines.push(
9860
+ ` ${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}`
9861
+ );
9862
+ }
9863
+ if (s.specs.length > SPEC_ROW_LIMIT) {
9864
+ lines.push(` … ${s.specs.length - SPEC_ROW_LIMIT} more spec(s) not shown (use --format json)`);
9865
+ }
9866
+ }
9867
+ return `${lines.join("\n")}
9868
+ `;
9869
+ }
8983
9870
  const program = new Command();
8984
9871
  program.name("yorz").description("YorZ CLI — manage the yorz-spec skill.").version(pkg.version);
8985
9872
  const uninstallCmd = program.command("uninstall").description("Remove YorZ artifacts (skills, ...).");
@@ -9043,6 +9930,18 @@ program.command("lint [paths...]").description("Lint spec.md / review.md files f
9043
9930
  if (result.exitCode !== 0) process.exit(result.exitCode);
9044
9931
  }
9045
9932
  );
9933
+ 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(
9934
+ async (opts) => {
9935
+ const result = await runMetrics({
9936
+ cwd: opts.cwd ?? process.cwd(),
9937
+ format: opts.format === "json" ? "json" : "text",
9938
+ project: opts.project,
9939
+ all: opts.all === true,
9940
+ since: opts.since
9941
+ });
9942
+ if (result.exitCode !== 0) process.exit(result.exitCode);
9943
+ }
9944
+ );
9046
9945
  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
9946
  "--host <address>",
9048
9947
  "bind address (default: 127.0.0.1; the service runs shell commands, so exposing it to the network is opt-in)"