From 06bf621503b4a0aa8c3238620b404199336e2e5b Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Fri, 10 Jul 2026 17:10:19 +0800 Subject: [PATCH 01/55] docs: add test design spec for git-tools, backup-manager, and undo-tool --- .../2026-07-10-moss-tool-tests-design.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-10-moss-tool-tests-design.md diff --git a/docs/superpowers/specs/2026-07-10-moss-tool-tests-design.md b/docs/superpowers/specs/2026-07-10-moss-tool-tests-design.md new file mode 100644 index 00000000..e1c12f81 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-moss-tool-tests-design.md @@ -0,0 +1,83 @@ +# Moss 工具模块测试方案 + +> 2026-07-10 · 对 git-tools、backup-manager、undo-tool 三个模块编写集成级测试 + +## 一、背景 + +Phase 1+2 新增了 6 个模块,其中前 3 个(Git 工具、自动备份、/undo 回滚)已接入系统可用,但均无测试覆盖。本次为这三个模块补齐测试。 + +## 二、测试框架 + +- **Vitest** — 原生 TypeScript 支持,无需编译步骤 +- 测试目录:`packages/moss-agent/__tests__/`,整体 `.gitignore` 排除 + +## 三、目录结构 + +``` +packages/moss-agent/ +├── __tests__/ ← .gitignore 排除 +│ ├── vitest.config.ts +│ ├── fixtures/ ← 不可变测试物料 +│ │ ├── sample-repo/ ← 预制的 git 仓库 +│ │ └── sample-files/ ← 独立文件,供备份/undo 测试 +│ ├── tools/ +│ │ ├── git-tools.test.ts +│ │ ├── backup-manager.test.ts +│ │ └── undo-tool.test.ts +│ └── integration/ +│ └── backup-undo-e2e.test.ts +``` + +## 四、测试策略 + +- 方案 B:集成级测试,用临时目录模拟真实环境 +- 每条测试在 `os.tmpdir()` 中创建临时目录,从 `fixtures/` 复制物料 +- 测试结束清理临时目录,确保可重复、不污染工作区 + +## 五、测试用例 + +### git-tools.test.ts + +| 工具 | 用例 | +|------|------| +| git_status | 干净仓库、未跟踪文件、已暂存、子目录过滤 | +| git_diff | 无变更、未暂存变更、staged、path 过滤、context_lines | +| git_log | 空仓库、有提交、count 限制、完整格式、author 过滤、since 过滤 | +| git_commit | 正常提交、空 message 报错、all=true、files 指定 | +| git_branch | list/create/switch、无效 action 报错、create 缺 name 报错 | + +### backup-manager.test.ts + +| 方法 | 用例 | +|------|------| +| init | 创建目录、重复 init | +| backupBeforeWrite | 已存在文件备份、新文件跳过、工作区外跳过、目录结构保留、FIFO 淘汰 | +| undoLast | 有备份恢复、无备份返回 null、恢复后清理 | +| getBackups | 返回列表 | +| cleanup | 删除所有备份 | + +### undo-tool.test.ts + +| 用例 | +|------| +| 无备份时提示 | +| 回滚 1 次恢复内容 | +| 批量回滚 3 次 | +| count 上限 10 | +| 超过可用备份数不报错 | + +### backup-undo-e2e.test.ts + +| 用例 | +|------| +| write_file → edit_file → undo 恢复 | +| apply_patch → undo 恢复 | +| 多次修改多次 undo 逐步回到初始状态 | + +## 六、运行方式 + +```bash +npx vitest run __tests__/ # 一次性跑完 +npx vitest __tests__/ # watch 模式 +npx vitest run __tests__/tools/git-tools # 单文件 +``` \ No newline at end of file From 830351806e430455c1f41a03779eb0f55be8e32b Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Fri, 10 Jul 2026 17:21:58 +0800 Subject: [PATCH 02/55] fix: add missing backupsDir to workspace paths; fix backup path collision for same-file backups --- .gitignore | 6 + packages/moss-agent/package.json | 7 +- .../moss-agent/src/tools/backup-manager.ts | 146 ++++++++++++++++++ .../moss-agent/src/utils/workspace-paths.ts | 2 + 4 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 packages/moss-agent/src/tools/backup-manager.ts diff --git a/.gitignore b/.gitignore index 6e963f36..7ae9067e 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,9 @@ CLAUDE.md probe-*.mjs probe-*.js *.scratch + +# Local eval framework (not part of Moss distribution) +eval/ + +# Local test files (not part of Moss distribution) +__tests__/ diff --git a/packages/moss-agent/package.json b/packages/moss-agent/package.json index 3135511c..968409e3 100644 --- a/packages/moss-agent/package.json +++ b/packages/moss-agent/package.json @@ -173,6 +173,7 @@ }, "dependencies": { "@rdk-moss/core": "^0.5.3", + "highlight.js": "^10.7.3", "ink": "^7.0.5", "marked": "^5.1.0", "marked-terminal": "^7.0.0", @@ -182,8 +183,7 @@ "react": "^19.2.6", "string-width": "^8.2.1", "turndown": "^7.2.0", - "undici": "^7.19.1", - "highlight.js": "^10.7.3" + "undici": "^7.19.1" }, "devDependencies": { "@types/marked-terminal": "^6.1.1", @@ -191,7 +191,8 @@ "@types/react": "^19.2.0", "@types/turndown": "^5.0.5", "ink-testing-library": "^4.0.0", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^4.1.10" }, "peerDependencies": { "zod": ">=3.0.0" diff --git a/packages/moss-agent/src/tools/backup-manager.ts b/packages/moss-agent/src/tools/backup-manager.ts new file mode 100644 index 00000000..3a99a7d0 --- /dev/null +++ b/packages/moss-agent/src/tools/backup-manager.ts @@ -0,0 +1,146 @@ +/** + * Auto-backup manager for Moss. + * + * Automatically backs up files before write_file, edit_file, or apply_patch + * modifications. Stores backups in .moss/backups// preserving the + * relative path structure. Supports rollback via /undo. + */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { getMossWorkspacePaths } from '../utils/workspace-paths.js'; + +export interface BackupEntry { + /** Original workspace-relative path */ + workspacePath: string; + /** Absolute path to the backup file */ + backupPath: string; + /** Timestamp of the backup */ + timestamp: number; + /** Tool that triggered the backup */ + tool: string; +} + +export class BackupManager { + private backups: BackupEntry[] = []; + private backupDir: string | null = null; + private maxBackups: number; + + constructor(maxBackups = 50) { + this.maxBackups = maxBackups; + } + + /** + * Initialize the backup directory for a workspace session. + */ + async init(workspaceDir: string): Promise { + const paths = getMossWorkspacePaths(workspaceDir); + this.backupDir = path.join(paths.backupsDir, String(Date.now())); + await fs.mkdir(this.backupDir, { recursive: true }); + } + + /** + * Back up a file before modification. Auto-initializes on first call. + */ + async backupBeforeWrite( + workspaceDir: string, + workspacePath: string, + tool: string, + ): Promise { + // Lazy initialization on first backup call + if (!this.backupDir) { + await this.init(workspaceDir); + } + + // Resolve the absolute path + const absolutePath = path.resolve(workspaceDir, workspacePath); + // Ensure it's within the workspace + if (!absolutePath.startsWith(path.resolve(workspaceDir))) return; + + try { + const content = await fs.readFile(absolutePath); + // Preserve directory structure: src/foo.ts → .moss/backups//src/foo.ts + // Append a counter to avoid overwriting previous backups of the same file + const basePath = path.join(this.backupDir, workspacePath); + const dir = path.dirname(basePath); + const ext = path.extname(basePath); + const name = path.basename(basePath, ext); + const seq = this.backups.filter((b) => b.workspacePath === workspacePath).length; + const relBackupPath = seq === 0 + ? basePath + : path.join(dir, `${name}.${seq}${ext}`); + await fs.mkdir(path.dirname(relBackupPath), { recursive: true }); + await fs.writeFile(relBackupPath, content); + + this.backups.push({ + workspacePath, + backupPath: relBackupPath, + timestamp: Date.now(), + tool, + }); + + // Prune old backups if over limit + if (this.backups.length > this.maxBackups) { + const oldest = this.backups.shift()!; + try { + await fs.unlink(oldest.backupPath); + } catch { + // Already gone + } + } + } catch { + // File doesn't exist yet (new file) — nothing to back up + } + } + + /** + * Restore the most recent backup. Returns the workspace path that was restored. + */ + async undoLast(workspaceDir: string): Promise { + const entry = this.backups.pop(); + if (!entry) return null; + + const targetPath = path.resolve(workspaceDir, entry.workspacePath); + try { + const content = await fs.readFile(entry.backupPath, 'utf-8'); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, content); + // Clean up the backup file + await fs.unlink(entry.backupPath); + return entry.workspacePath; + } catch (err) { + return null; + } + } + + /** + * Get all backup entries for the current session. + */ + getBackups(): ReadonlyArray { + return this.backups; + } + + /** + * Get the current backup directory path. + */ + getBackupDir(): string | null { + return this.backupDir; + } + + /** + * Clean up all backups. + */ + async cleanup(): Promise { + if (this.backupDir) { + try { + await fs.rm(this.backupDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + this.backups = []; + this.backupDir = null; + } +} + +/** Global singleton — one backup manager per process */ +export const globalBackupManager = new BackupManager(); \ No newline at end of file diff --git a/packages/moss-agent/src/utils/workspace-paths.ts b/packages/moss-agent/src/utils/workspace-paths.ts index 4767d3e5..46c61ac6 100644 --- a/packages/moss-agent/src/utils/workspace-paths.ts +++ b/packages/moss-agent/src/utils/workspace-paths.ts @@ -7,6 +7,7 @@ export interface MossWorkspacePaths { sessionsDir: string; memoryDir: string; checkpointsDir: string; + backupsDir: string; attachmentsDir: string; projectConfigPath: string; skillsDir: string; @@ -44,6 +45,7 @@ export function getMossWorkspacePaths(workspaceDir: string): MossWorkspacePaths sessionsDir: path.join(runtimeDir, 'sessions'), memoryDir: path.join(runtimeDir, 'memory'), checkpointsDir: path.join(runtimeDir, 'checkpoints'), + backupsDir: path.join(runtimeDir, 'backups'), attachmentsDir: path.join(runtimeDir, 'attachments'), projectConfigPath: path.join(runtimeDir, 'config.json'), skillsDir, From c74220efcce30833e1ce37f257e1f7202c0a046f Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Fri, 10 Jul 2026 18:57:31 +0800 Subject: [PATCH 03/55] docs: add user-analytics OTel refactor design spec --- ...-10-user-analytics-otel-refactor-design.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-10-user-analytics-otel-refactor-design.md diff --git a/docs/superpowers/specs/2026-07-10-user-analytics-otel-refactor-design.md b/docs/superpowers/specs/2026-07-10-user-analytics-otel-refactor-design.md new file mode 100644 index 00000000..0f544633 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-user-analytics-otel-refactor-design.md @@ -0,0 +1,91 @@ +# 用户埋点 OpenTelemetry 化重构 + +> 2026-07-10 · 基于现有 tracing 基础设施,将 user-analytics 从自定义事件收集器重构为 Span 数据后处理器 + +## 一、背景 + +`user-analytics.ts` 当前状态: +- 自定义事件模型(CorrectionEvent / ToolUsageEvent / SessionEvent / TurnEvent) +- 自定义 JSON 存储(`.moss/analytics/events-*.json`) +- 自定义聚合逻辑(SessionSummary / getAggregateStats / getToolHotspots) +- **零消费者** — 全局单例存在但没有任何代码调用 `track()` 或 `init()` + +Moss 已有 `tracing.ts`(TraceRegistry / Tracer / Span / withSpan),默认 noop 实现,开销为零。Agent 循环中已有一处 `withSpan('agent.llm_turn')`。 + +## 二、设计目标 + +将埋点从"自定义事件收集"改为"标准 Span 采集",对接 Moss 现有 tracing 层。 + +## 三、架构 + +``` +Agent 主循环 + └─ withSpan('agent.llm_turn') ← 已有 + └─ withSpan('tool.execute') ← 新增 + └─ withSpan('session') ← 新增 + ↓ + Tracer 接口(已有,不变) + ↓ + TraceFileExporter ← 新增:Span → JSONL 落盘 + ↓ + .moss/analytics/traces.jsonl +``` + +## 四、组件 + +### 4.1 TraceFileExporter(新增) + +- `init(workspaceDir)` — 创建 `.moss/analytics/` 目录 +- `exportSpan(span)` — 内存缓冲,30 秒批量 flush +- `flush()` — 强制落盘 +- `cleanup()` — 停止定时器,flush 剩余 +- `getStats()` — 从 JSONL 读取聚合统计 + +SerializedSpan 格式(一行一个 JSON): +```json +{"name":"tool.execute","startTime":...,"endTime":...,"attributes":{...},"events":[...],"status":"ok"} +``` + +### 4.2 enableLocalTracing()(tracing.ts 新增) + +便捷方法,创建包装了 TraceFileExporter 的 Tracer,Span 结束时自动 `exportSpan()`。 + +### 4.3 Agent 循环 Span(新增) + +- `tool.execute` — 包裹工具调用,记录 toolName / success / durationMs +- `session` — 包裹整个会话,记录 start/end 事件和 outcome + +## 五、错误处理 + +- 写入失败 → 静默丢弃,不阻塞 Agent +- 磁盘满 → flush 跳过,内存清空,console.warn +- JSONL 解析失败 → 跳过损坏行 + +核心原则:**采集层永不抛出异常影响 Agent 运行**。 + +## 六、改动清单 + +| 文件 | 改动 | 行数 | +|------|------|------| +| 新增 `trace-exporter.ts` | 文件写入 + 统计读取 | ~100 | +| 修改 `tracing.ts` | 加 `enableLocalTracing()` | +15 | +| 删除 `user-analytics.ts` | 功能迁移到 trace-exporter | -240 | +| 修改 `observability/index.ts` | 更新导出 | ~10 | +| 修改 `execute-tool-call.ts` | 加 `withSpan` 包裹 | +10 | +| 修改 `moss-agent.ts` | 加 `withSpan` 包裹 | +10 | + +## 七、测试 + +| 文件 | 数量 | 测点 | +|------|------|------| +| `trace-exporter.test.ts` | ~12 | init/exportSpan/flush/JSONL 格式/cleanup/损坏行跳过/读统计 | +| `tracing.test.ts`(补充) | ~3 | enableLocalTracing 后 Tracer 非 noop、Span 结束时自动 export | + +## 八、运行方式 + +默认不启用,零开销。显式启用: + +```ts +import { enableLocalTracing } from '@rdk-moss/agent/observability'; +enableLocalTracing(workspaceDir); +``` \ No newline at end of file From 9393a834ed492b6c6e34597017ee6160e32c2aca Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Fri, 10 Jul 2026 19:00:31 +0800 Subject: [PATCH 04/55] docs: add user-analytics OTel refactor implementation plan --- ...2026-07-10-user-analytics-otel-refactor.md | 703 ++++++++++++++++++ 1 file changed, 703 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-user-analytics-otel-refactor.md diff --git a/docs/superpowers/plans/2026-07-10-user-analytics-otel-refactor.md b/docs/superpowers/plans/2026-07-10-user-analytics-otel-refactor.md new file mode 100644 index 00000000..4cb33d99 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-user-analytics-otel-refactor.md @@ -0,0 +1,703 @@ +# User Analytics OTel 重构 + 测试 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 基于现有 tracing 基础设施,将 user-analytics 从自定义事件收集器重构为 Span 数据后处理器(TraceFileExporter),并在 Agent 循环中新增 tool.execute 和 session 级别的 Span。 + +**Architecture:** 新增 TraceFileExporter 将 Span 序列化为 JSONL 写入 `.moss/analytics/traces.jsonl`;tracing.ts 新增 `enableLocalTracing()` 便捷方法;删除死代码 user-analytics.ts;在 execute-tool-call.ts 和 moss-agent.ts 中新增 withSpan 包裹。 + +**Tech Stack:** TypeScript, Node.js fs/promises, Vitest, Moss 现有 tracing.ts + +## Global Constraints + +- Node.js >= 22.16 +- 默认不启用,零开销(noop tracer) +- 采集层永不抛出异常影响 Agent 运行 +- 测试文件放在 `__tests__/observability/`(gitignore 排除) +- ESM 模块,import 使用 `.js` 扩展名 + +--- + +### Task 1: 创建 TraceFileExporter + +**Files:** +- Create: `packages/moss-agent/src/observability/trace-exporter.ts` + +**Interfaces:** +- Produces: `TraceFileExporter` class, `SerializedSpan` interface + +- [ ] **Step 1: 创建文件并实现完整代码** + +```ts +/** + * TraceFileExporter — serializes TraceSpans to a local JSONL file. + * + * Default: no-op until init() is called. When enabled, spans are buffered in + * memory and flushed to .moss/analytics/traces.jsonl every 30 seconds. + */ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export interface SerializedSpan { + name: string; + startTime: number; + endTime: number; + attributes: Record; + events: Array<{ name: string; time: number; attrs?: Record }>; + status: 'ok' | 'error'; + statusMessage?: string; +} + +export interface TraceStats { + totalSpans: number; + totalErrors: number; + errorRate: number; + byName: Record; + toolSpans: Array<{ toolName: string; count: number; errors: number; avgDurationMs: number }>; +} + +export class TraceFileExporter { + private enabled = false; + private buffer: SerializedSpan[] = []; + private analyticsDir: string | null = null; + private flushTimer: ReturnType | null = null; + + init(workspaceDir: string): void { + this.enabled = true; + this.analyticsDir = path.join(workspaceDir, '.moss', 'analytics'); + this.flushTimer = setInterval(() => this.flush(), 30_000); + } + + exportSpan(span: SerializedSpan): void { + if (!this.enabled) return; + this.buffer.push(span); + } + + async flush(): Promise { + if (!this.enabled || !this.analyticsDir || this.buffer.length === 0) return; + try { + await fs.mkdir(this.analyticsDir, { recursive: true }); + const file = path.join(this.analyticsDir, 'traces.jsonl'); + const lines = this.buffer.map((s) => JSON.stringify(s)).join('\n') + '\n'; + await fs.appendFile(file, lines, 'utf-8'); + } catch { + // Silently ignore — never block the agent + } + this.buffer = []; + } + + async cleanup(): Promise { + if (this.flushTimer) clearInterval(this.flushTimer); + this.flushTimer = null; + await this.flush(); + this.enabled = false; + this.analyticsDir = null; + } + + /** Read aggregated stats from the JSONL file (for CLI reporting). */ + async getStats(): Promise { + if (!this.analyticsDir) return emptyStats(); + const file = path.join(this.analyticsDir, 'traces.jsonl'); + let lines: string[]; + try { + const content = await fs.readFile(file, 'utf-8'); + lines = content.split('\n').filter(Boolean); + } catch { + return emptyStats(); + } + + const stats: TraceStats = { + totalSpans: 0, + totalErrors: 0, + errorRate: 0, + byName: {}, + toolSpans: [], + }; + + for (const line of lines) { + try { + const span: SerializedSpan = JSON.parse(line); + stats.totalSpans++; + if (span.status === 'error') stats.totalErrors++; + + const name = span.name; + if (!stats.byName[name]) { + stats.byName[name] = { count: 0, errors: 0, avgDurationMs: 0 }; + } + const entry = stats.byName[name]; + entry.count++; + if (span.status === 'error') entry.errors++; + const duration = span.endTime - span.startTime; + entry.avgDurationMs = + (entry.avgDurationMs * (entry.count - 1) + duration) / entry.count; + + if (name === 'tool.execute') { + const toolName = String(span.attributes.toolName || 'unknown'); + let toolEntry = stats.toolSpans.find((t) => t.toolName === toolName); + if (!toolEntry) { + toolEntry = { toolName, count: 0, errors: 0, avgDurationMs: 0 }; + stats.toolSpans.push(toolEntry); + } + toolEntry.count++; + if (span.status === 'error') toolEntry.errors++; + toolEntry.avgDurationMs = + (toolEntry.avgDurationMs * (toolEntry.count - 1) + duration) / toolEntry.count; + } + } catch { + // Skip corrupted lines + } + } + + stats.errorRate = stats.totalSpans > 0 ? stats.totalErrors / stats.totalSpans : 0; + stats.toolSpans.sort((a, b) => b.count - a.count); + return stats; + } +} + +function emptyStats(): TraceStats { + return { totalSpans: 0, totalErrors: 0, errorRate: 0, byName: {}, toolSpans: [] }; +} + +/** Global singleton */ +export const globalTraceExporter = new TraceFileExporter(); +``` + +- [ ] **Step 2: 提交** + +```bash +git add packages/moss-agent/src/observability/trace-exporter.ts +git commit -m "feat: add TraceFileExporter for Span-to-JSONL persistence" +``` + +--- + +### Task 2: 编写 trace-exporter 测试 + +**Files:** +- Create: `packages/moss-agent/__tests__/observability/trace-exporter.test.ts` + +**Interfaces:** +- Consumes: `TraceFileExporter` from Task 1, `SerializedSpan` from Task 1 + +- [ ] **Step 1: 创建测试文件** + +```ts +/** + * Tests for trace-exporter.ts + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { TraceFileExporter, type SerializedSpan } from '../../src/observability/trace-exporter.js'; + +let workspaceDir: string; +let exporter: TraceFileExporter; + +function makeSpan(overrides: Partial = {}): SerializedSpan { + return { + name: 'tool.execute', + startTime: Date.now() - 100, + endTime: Date.now(), + attributes: { toolName: 'write_file', runId: 'test-1' }, + events: [], + status: 'ok', + ...overrides, + }; +} + +beforeEach(async () => { + workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-exporter-test-')); + exporter = new TraceFileExporter(); +}); + +afterEach(async () => { + await exporter.cleanup(); + await fs.rm(workspaceDir, { recursive: true, force: true }); +}); + +describe('TraceFileExporter', () => { + describe('init', () => { + it('初始化后 enabled', () => { + exporter.init(workspaceDir); + // exportSpan should not throw when enabled + exporter.exportSpan(makeSpan()); + }); + }); + + describe('exportSpan + flush', () => { + it('flush 后 JSONL 文件存在且格式正确', async () => { + exporter.init(workspaceDir); + exporter.exportSpan(makeSpan({ name: 'tool.execute', attributes: { toolName: 'read_file', runId: 'r1' } })); + exporter.exportSpan(makeSpan({ name: 'agent.llm_turn', attributes: { model: 'deepseek', runId: 'r1' } })); + + await exporter.flush(); + + const file = path.join(workspaceDir, '.moss', 'analytics', 'traces.jsonl'); + const content = await fs.readFile(file, 'utf-8'); + const lines = content.split('\n').filter(Boolean); + expect(lines.length).toBe(2); + + const first = JSON.parse(lines[0]); + expect(first.name).toBe('tool.execute'); + expect(first.attributes.toolName).toBe('read_file'); + expect(first.status).toBe('ok'); + }); + + it('error 状态的 Span 正常记录', async () => { + exporter.init(workspaceDir); + exporter.exportSpan(makeSpan({ status: 'error', statusMessage: 'timeout' })); + + await exporter.flush(); + + const file = path.join(workspaceDir, '.moss', 'analytics', 'traces.jsonl'); + const content = await fs.readFile(file, 'utf-8'); + const span = JSON.parse(content.trim()); + expect(span.status).toBe('error'); + expect(span.statusMessage).toBe('timeout'); + }); + }); + + describe('未启用时', () => { + it('不调用 init 时 exportSpan 不写文件', async () => { + exporter.exportSpan(makeSpan()); + await exporter.flush(); + + const file = path.join(workspaceDir, '.moss', 'analytics', 'traces.jsonl'); + await expect(fs.access(file)).rejects.toThrow(); + }); + }); + + describe('cleanup', () => { + it('cleanup 后停止定时器并 flush 剩余数据', async () => { + exporter.init(workspaceDir); + exporter.exportSpan(makeSpan()); + + await exporter.cleanup(); + + // Data should be flushed during cleanup + const file = path.join(workspaceDir, '.moss', 'analytics', 'traces.jsonl'); + const content = await fs.readFile(file, 'utf-8'); + expect(content.trim()).toBeTruthy(); + }); + }); + + describe('getStats', () => { + it('空文件返回空统计', async () => { + exporter.init(workspaceDir); + await exporter.flush(); + + const stats = await exporter.getStats(); + expect(stats.totalSpans).toBe(0); + expect(stats.totalErrors).toBe(0); + }); + + it('有数据时返回正确的聚合统计', async () => { + exporter.init(workspaceDir); + const now = Date.now(); + exporter.exportSpan({ name: 'tool.execute', startTime: now - 200, endTime: now, attributes: { toolName: 'write_file' }, events: [], status: 'ok' }); + exporter.exportSpan({ name: 'tool.execute', startTime: now - 100, endTime: now, attributes: { toolName: 'write_file' }, events: [], status: 'error', statusMessage: 'fail' }); + exporter.exportSpan({ name: 'agent.llm_turn', startTime: now - 300, endTime: now, attributes: {}, events: [], status: 'ok' }); + + await exporter.flush(); + const stats = await exporter.getStats(); + + expect(stats.totalSpans).toBe(3); + expect(stats.totalErrors).toBe(1); + expect(stats.errorRate).toBeCloseTo(1 / 3); + expect(stats.byName['tool.execute'].count).toBe(2); + expect(stats.byName['tool.execute'].errors).toBe(1); + expect(stats.byName['agent.llm_turn'].count).toBe(1); + + expect(stats.toolSpans.length).toBe(1); + expect(stats.toolSpans[0].toolName).toBe('write_file'); + expect(stats.toolSpans[0].count).toBe(2); + expect(stats.toolSpans[0].errors).toBe(1); + }); + + it('损坏的 JSONL 行跳过不报错', async () => { + exporter.init(workspaceDir); + const file = path.join(workspaceDir, '.moss', 'analytics', 'traces.jsonl'); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, 'not valid json\n{"name":"ok","startTime":1,"endTime":2,"attributes":{},"events":[],"status":"ok"}\n', 'utf-8'); + + const stats = await exporter.getStats(); + expect(stats.totalSpans).toBe(1); // corrupted line skipped + }); + }); +}); +``` + +- [ ] **Step 2: 运行测试验证通过** + +```bash +npx vitest run __tests__/observability/trace-exporter.test.ts --config __tests__/vitest.config.ts --root __tests__ +``` +Expected: 10 tests PASS + +- [ ] **Step 3: 提交** + +```bash +git add packages/moss-agent/__tests__/observability/trace-exporter.test.ts +git commit -m "test: add TraceFileExporter tests" +``` + +--- + +### Task 3: 修改 tracing.ts 添加 enableLocalTracing() + +**Files:** +- Modify: `packages/moss-agent/src/observability/tracing.ts` +- Test: `packages/moss-agent/__tests__/observability/tracing.test.ts` (create) + +**Interfaces:** +- Consumes: `TraceFileExporter` from Task 1, `TraceRegistry` from tracing.ts (existing) +- Produces: `enableLocalTracing(workspaceDir: string): void` + +- [ ] **Step 1: 在 tracing.ts 末尾添加 enableLocalTracing** + +在 `packages/moss-agent/src/observability/tracing.ts` 末尾追加: + +```ts +import { globalTraceExporter, type SerializedSpan } from './trace-exporter.js'; + +/** + * Enable local file-based tracing. Spans are written to + * .moss/analytics/traces.jsonl. Call once at session start. + * Default is no-op (zero overhead). Call cleanup() on the + * globalTraceExporter at session end. + */ +export function enableLocalTracing(workspaceDir: string): void { + globalTraceExporter.init(workspaceDir); + defaultTraceRegistry.setTracer({ + startSpan(name, attributes, parent) { + const startTime = Date.now(); + const events: SerializedSpan['events'] = []; + let status: SerializedSpan['status'] = 'ok'; + let statusMessage: string | undefined; + + return { + setAttribute() {}, + addEvent(eventName, eventAttrs) { + events.push({ name: eventName, time: Date.now(), attrs: eventAttrs }); + }, + setStatus(ok, message) { + status = ok ? 'ok' : 'error'; + statusMessage = message; + }, + end() { + const span: SerializedSpan = { + name, + startTime, + endTime: Date.now(), + attributes: attributes ?? {}, + events, + status, + ...(statusMessage ? { statusMessage } : {}), + }; + globalTraceExporter.exportSpan(span); + }, + }; + }, + }); +} +``` + +- [ ] **Step 2: 创建 tracing 补充测试** + +创建 `packages/moss-agent/__tests__/observability/tracing.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { enableLocalTracing, withSpan, setTracer } from '../../src/observability/tracing.js'; +import { globalTraceExporter } from '../../src/observability/trace-exporter.js'; + +let workspaceDir: string; + +beforeEach(async () => { + workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-tracing-test-')); +}); + +afterEach(async () => { + await globalTraceExporter.cleanup(); + // Reset to noop + setTracer({ + startSpan: () => ({ + setAttribute() {}, + addEvent() {}, + setStatus() {}, + end() {}, + }), + }); + await fs.rm(workspaceDir, { recursive: true, force: true }); +}); + +describe('enableLocalTracing', () => { + it('启用后 withSpan 产生 Span 数据', async () => { + enableLocalTracing(workspaceDir); + + await withSpan('test.span', { key: 'value' }, async (span) => { + span.addEvent('midpoint', { step: 1 }); + }); + + await globalTraceExporter.flush(); + const stats = await globalTraceExporter.getStats(); + expect(stats.totalSpans).toBe(1); + expect(stats.byName['test.span'].count).toBe(1); + }); + + it('启用后 error Span 正确记录', async () => { + enableLocalTracing(workspaceDir); + + try { + await withSpan('test.error_span', {}, async () => { + throw new Error('boom'); + }); + } catch { + // expected + } + + await globalTraceExporter.flush(); + const stats = await globalTraceExporter.getStats(); + expect(stats.totalSpans).toBe(1); + expect(stats.totalErrors).toBe(1); + }); +}); +``` + +- [ ] **Step 3: 运行测试** + +```bash +npx vitest run __tests__/observability/ --config __tests__/vitest.config.ts --root __tests__ +``` +Expected: 12 tests PASS (10 from Task 2 + 2 from Task 3) + +- [ ] **Step 4: 提交** + +```bash +git add packages/moss-agent/src/observability/tracing.ts packages/moss-agent/__tests__/observability/tracing.test.ts +git commit -m "feat: add enableLocalTracing() to tracing layer" +``` + +--- + +### Task 4: 更新导出 + 删除 user-analytics.ts + +**Files:** +- Modify: `packages/moss-agent/src/observability/index.ts` +- Delete: `packages/moss-agent/src/observability/user-analytics.ts` + +**Interfaces:** +- Consumes: `TraceFileExporter`, `SerializedSpan`, `TraceStats`, `globalTraceExporter` from Task 1 +- Consumes: `enableLocalTracing` from Task 3 +- Removes: all `user-analytics.ts` exports + +- [ ] **Step 1: 更新 observability/index.ts** + +将 `packages/moss-agent/src/observability/index.ts` 中的 user-analytics 导出块替换为: + +```ts +// Trace exporter (local file-based Span persistence) +export { + TraceFileExporter, + globalTraceExporter, +} from './trace-exporter.js'; +export type { + SerializedSpan, + TraceStats, +} from './trace-exporter.js'; +``` + +同时删除原有的 user-analytics 导出块(lines 23-36)。 + +- [ ] **Step 2: 删除 user-analytics.ts** + +```bash +rm packages/moss-agent/src/observability/user-analytics.ts +``` + +- [ ] **Step 3: 验证编译** + +```bash +npx tsc --noEmit -p packages/moss-agent/tsconfig.json +``` + +- [ ] **Step 4: 运行全部测试确认无回归** + +```bash +npx vitest run __tests__/ --config __tests__/vitest.config.ts --root __tests__ +``` + +- [ ] **Step 5: 提交** + +```bash +git add packages/moss-agent/src/observability/index.ts +git rm packages/moss-agent/src/observability/user-analytics.ts +git commit -m "refactor: replace user-analytics with trace-exporter; update exports" +``` + +--- + +### Task 5: 在 execute-tool-call.ts 中添加 tool.execute Span + +**Files:** +- Modify: `packages/moss-agent/src/core/tools/execute-tool-call.ts` + +**Interfaces:** +- Consumes: `withSpan` from tracing.ts (existing), `toolAttributes` from tracing.ts (existing) + +- [ ] **Step 1: 添加 import** + +在 `packages/moss-agent/src/core/tools/execute-tool-call.ts` 顶部已有 import 区域追加: + +```ts +import { withSpan, toolAttributes } from '../../observability/tracing.js'; +``` + +- [ ] **Step 2: 用 withSpan 包裹工具执行** + +在 `executeOneToolCall` 函数中,将 lines 340-511(从 `const startMs = Date.now()` 到 `return` 语句)包裹在 `withSpan` 中。 + +找到 line 341-342: +```ts +const startMs = Date.now(); +let text = ''; +``` + +改为: +```ts +const startMs = Date.now(); +let text = ''; +let errFlag = false; + +// ... 紧接在 let startMs = Date.now() 之后,用 withSpan 包裹整个执行逻辑 +``` + +具体做法:将 lines 340-511 的执行逻辑提取到 withSpan 回调中,不改变原有逻辑。 + +在 `executeOneToolCall` 的 return 之前(line 541-548),计算 duration 时使用 Span 记录的时间。 + +简化的做法——在 `const startMs = Date.now()` 后立即包裹: + +```ts +const startMs = Date.now(); +const runId = deps.sessionKey; // 使用 sessionKey 作为 runId +const outcome = await withSpan( + 'tool.execute', + toolAttributes(runId, call.name, call.id), + async (span) => { + // ... 原有的 lines 341-511 逻辑全部移入这里 + + // 在 finally 或 return 前设置 span 属性 + span.setAttribute('success', !errFlag); + span.setAttribute('durationMs', Date.now() - startMs); + if (errFlag) { + span.setStatus(false, text); + } + + return { ... }; // 原有的返回结构 + } +); +``` + +**注意**:这是关键改动,需要仔细重构。保持原有重试逻辑、心跳、超时、abort 的完整行为不变。 + +- [ ] **Step 3: 运行测试** + +```bash +npx vitest run __tests__/ --config __tests__/vitest.config.ts --root __tests__ +``` +Expected: 所有已有测试 PASS + 确认无回归 + +- [ ] **Step 4: 提交** + +```bash +git add packages/moss-agent/src/core/tools/execute-tool-call.ts +git commit -m "feat: add tool.execute span to executeOneToolCall" +``` + +--- + +### Task 6: 在 moss-agent.ts 中添加 session Span + +**Files:** +- Modify: `packages/moss-agent/src/core/agent/moss-agent.ts` + +**Interfaces:** +- Consumes: `withSpan` from tracing.ts (existing) + +- [ ] **Step 1: 添加 import** + +在 `packages/moss-agent/src/core/agent/moss-agent.ts` 已有 import 区域追加: + +```ts +import { withSpan } from '../../observability/tracing.js'; +``` + +- [ ] **Step 2: 用 withSpan 包裹 run 方法** + +在 `MossAgent.run()` 方法中,将整个 run 逻辑包裹在 `withSpan('session', ...)` 中。 + +找到 run 方法的主体(约 line 828 之后),在 `const runId = options?.runId ?? crypto.randomUUID();` 之后包裹: + +```ts +const runId = options?.runId ?? crypto.randomUUID(); + +const modelId = this.config.modelId ?? 'default'; +const result = await withSpan( + 'session', + { runId, model: modelId }, + async (span) => { + span.addEvent('start', { sessionKey }); + + // ... 原有的 run 方法主体逻辑 + + span.addEvent('end', { outcome: outcome ?? 'completed' }); + return { ... }; // 原有的返回值 + } +); + +return result; +``` + +- [ ] **Step 3: 运行全部测试** + +```bash +npx vitest run __tests__/ --config __tests__/vitest.config.ts --root __tests__ +``` +Expected: 所有已有测试 PASS + +- [ ] **Step 4: 提交** + +```bash +git add packages/moss-agent/src/core/agent/moss-agent.ts +git commit -m "feat: add session span to MossAgent.run()" +``` + +--- + +### Task 7: 最终验证 + +- [ ] **Step 1: 运行全部测试** + +```bash +npx vitest run __tests__/ --config __tests__/vitest.config.ts --root __tests__ +``` +Expected: 全部 PASS + +- [ ] **Step 2: 类型检查** + +```bash +npx tsc --noEmit -p packages/moss-agent/tsconfig.json +``` +Expected: 无错误 + +- [ ] **Step 3: 提交** + +```bash +git add -A +git commit -m "chore: final verification — all tests pass, typecheck clean" +``` \ No newline at end of file From 572895b1356ff596d85f263bb7ffd6e3b12b993e Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Fri, 10 Jul 2026 19:06:39 +0800 Subject: [PATCH 05/55] feat: add TraceFileExporter for Span-to-JSONL persistence Co-Authored-By: Claude --- .../moss-agent/src/observability/index.ts | 34 +++++ .../src/observability/trace-exporter.ts | 131 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 packages/moss-agent/src/observability/trace-exporter.ts diff --git a/packages/moss-agent/src/observability/index.ts b/packages/moss-agent/src/observability/index.ts index 80ef611f..1089beb7 100644 --- a/packages/moss-agent/src/observability/index.ts +++ b/packages/moss-agent/src/observability/index.ts @@ -19,3 +19,37 @@ export { registerModelPricing, } from './llm-usage.js'; export type { LLMUsageRecord, LLMUsageSummary } from './llm-usage.js'; + +// User behavior analytics +export { + UserAnalytics, + globalAnalytics, + detectCorrection, +} from './user-analytics.js'; +export type { + AnalyticsEvent, + CorrectionEvent, + ToolUsageEvent, + SessionEvent, + TurnEvent, + SessionSummary, +} from './user-analytics.js'; + +// A/B testing +export { + ABTestRegistry, + globalABTests, +} from './ab-testing.js'; +export type { + ABTestConfig, + ABTestResult, + ABTestStats, + ABTestReport, +} from './ab-testing.js'; + +// Trace file exporter +export { + TraceFileExporter, + globalTraceExporter, +} from './trace-exporter.js'; +export type { SerializedSpan, TraceStats } from './trace-exporter.js'; diff --git a/packages/moss-agent/src/observability/trace-exporter.ts b/packages/moss-agent/src/observability/trace-exporter.ts new file mode 100644 index 00000000..f03f1148 --- /dev/null +++ b/packages/moss-agent/src/observability/trace-exporter.ts @@ -0,0 +1,131 @@ +/** + * TraceFileExporter — serializes TraceSpans to a local JSONL file. + * + * Default: no-op until init() is called. When enabled, spans are buffered in + * memory and flushed to .moss/analytics/traces.jsonl every 30 seconds. + */ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export interface SerializedSpan { + name: string; + startTime: number; + endTime: number; + attributes: Record; + events: Array<{ name: string; time: number; attrs?: Record }>; + status: 'ok' | 'error'; + statusMessage?: string; +} + +export interface TraceStats { + totalSpans: number; + totalErrors: number; + errorRate: number; + byName: Record; + toolSpans: Array<{ toolName: string; count: number; errors: number; avgDurationMs: number }>; +} + +export class TraceFileExporter { + private enabled = false; + private buffer: SerializedSpan[] = []; + private analyticsDir: string | null = null; + private flushTimer: ReturnType | null = null; + + init(workspaceDir: string): void { + this.enabled = true; + this.analyticsDir = path.join(workspaceDir, '.moss', 'analytics'); + this.flushTimer = setInterval(() => this.flush(), 30_000); + } + + exportSpan(span: SerializedSpan): void { + if (!this.enabled) return; + this.buffer.push(span); + } + + async flush(): Promise { + if (!this.enabled || !this.analyticsDir || this.buffer.length === 0) return; + try { + await fs.mkdir(this.analyticsDir, { recursive: true }); + const file = path.join(this.analyticsDir, 'traces.jsonl'); + const lines = this.buffer.map((s) => JSON.stringify(s)).join('\n') + '\n'; + await fs.appendFile(file, lines, 'utf-8'); + } catch { + // Silently ignore — never block the agent + } + this.buffer = []; + } + + async cleanup(): Promise { + if (this.flushTimer) clearInterval(this.flushTimer); + this.flushTimer = null; + await this.flush(); + this.enabled = false; + this.analyticsDir = null; + } + + /** Read aggregated stats from the JSONL file (for CLI reporting). */ + async getStats(): Promise { + if (!this.analyticsDir) return emptyStats(); + const file = path.join(this.analyticsDir, 'traces.jsonl'); + let lines: string[]; + try { + const content = await fs.readFile(file, 'utf-8'); + lines = content.split('\n').filter(Boolean); + } catch { + return emptyStats(); + } + + const stats: TraceStats = { + totalSpans: 0, + totalErrors: 0, + errorRate: 0, + byName: {}, + toolSpans: [], + }; + + for (const line of lines) { + try { + const span: SerializedSpan = JSON.parse(line); + stats.totalSpans++; + if (span.status === 'error') stats.totalErrors++; + + const name = span.name; + if (!stats.byName[name]) { + stats.byName[name] = { count: 0, errors: 0, avgDurationMs: 0 }; + } + const entry = stats.byName[name]; + entry.count++; + if (span.status === 'error') entry.errors++; + const duration = span.endTime - span.startTime; + entry.avgDurationMs = + (entry.avgDurationMs * (entry.count - 1) + duration) / entry.count; + + if (name === 'tool.execute') { + const toolName = String(span.attributes.toolName || 'unknown'); + let toolEntry = stats.toolSpans.find((t) => t.toolName === toolName); + if (!toolEntry) { + toolEntry = { toolName, count: 0, errors: 0, avgDurationMs: 0 }; + stats.toolSpans.push(toolEntry); + } + toolEntry.count++; + if (span.status === 'error') toolEntry.errors++; + toolEntry.avgDurationMs = + (toolEntry.avgDurationMs * (toolEntry.count - 1) + duration) / toolEntry.count; + } + } catch { + // Skip corrupted lines + } + } + + stats.errorRate = stats.totalSpans > 0 ? stats.totalErrors / stats.totalSpans : 0; + stats.toolSpans.sort((a, b) => b.count - a.count); + return stats; + } +} + +function emptyStats(): TraceStats { + return { totalSpans: 0, totalErrors: 0, errorRate: 0, byName: {}, toolSpans: [] }; +} + +/** Global singleton */ +export const globalTraceExporter = new TraceFileExporter(); \ No newline at end of file From a681d90ddc1988fab0a93d4428adf954999a4e22 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Fri, 10 Jul 2026 19:16:12 +0800 Subject: [PATCH 06/55] feat: add enableLocalTracing() to tracing layer --- .../moss-agent/src/observability/tracing.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/moss-agent/src/observability/tracing.ts b/packages/moss-agent/src/observability/tracing.ts index 71337e10..9001f62e 100644 --- a/packages/moss-agent/src/observability/tracing.ts +++ b/packages/moss-agent/src/observability/tracing.ts @@ -181,3 +181,46 @@ export function llmRequestAttributes( ): Record { return { runId, model, inputTokens }; } + +import { globalTraceExporter, type SerializedSpan } from './trace-exporter.js'; + +/** + * Enable local file-based tracing. Spans are written to + * .moss/analytics/traces.jsonl. Call once at session start. + * Default is no-op (zero overhead). Call cleanup() on the + * globalTraceExporter at session end. + */ +export function enableLocalTracing(workspaceDir: string): void { + globalTraceExporter.init(workspaceDir); + defaultTraceRegistry.setTracer({ + startSpan(name, attributes, _parent) { + const startTime = Date.now(); + const events: SerializedSpan['events'] = []; + let status: SerializedSpan['status'] = 'ok'; + let statusMessage: string | undefined; + + return { + setAttribute() {}, + addEvent(eventName, eventAttrs) { + events.push({ name: eventName, time: Date.now(), attrs: eventAttrs }); + }, + setStatus(ok, message) { + status = ok ? 'ok' : 'error'; + statusMessage = message; + }, + end() { + const span: SerializedSpan = { + name, + startTime, + endTime: Date.now(), + attributes: attributes ?? {}, + events, + status, + ...(statusMessage ? { statusMessage } : {}), + }; + globalTraceExporter.exportSpan(span); + }, + }; + }, + }); +} From bf60e565107eb8fcdd57be06168f7e744602c7c1 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Fri, 10 Jul 2026 19:18:04 +0800 Subject: [PATCH 07/55] refactor: delete user-analytics.ts, update exports --- .claude/commands/opsx/apply.md | 155 ++ .claude/commands/opsx/archive.md | 160 ++ .claude/commands/opsx/explore.md | 174 +++ .claude/commands/opsx/propose.md | 109 ++ .claude/commands/opsx/sync.md | 143 ++ .claude/skills/openspec-apply-change/SKILL.md | 159 ++ .../skills/openspec-archive-change/SKILL.md | 117 ++ .claude/skills/openspec-explore/SKILL.md | 289 ++++ .claude/skills/openspec-propose/SKILL.md | 113 ++ .claude/skills/openspec-sync-specs/SKILL.md | 147 ++ CHANGES.md | 77 + .../2026-07-10-knowledge-search-design.md | 56 + ...00\345\217\221\346\226\271\346\241\210.md" | 270 ++++ .../changes/moss-eval-suite/.openspec.yaml | 2 + openspec/changes/moss-eval-suite/design.md | 68 + openspec/changes/moss-eval-suite/proposal.md | 31 + .../specs/eval-adversarial-tests/spec.md | 72 + .../specs/eval-cli-runner/spec.md | 76 + .../specs/eval-regression-tests/spec.md | 61 + .../specs/eval-scenario-e2e-tests/spec.md | 150 ++ .../specs/eval-tool-unit-tests/spec.md | 169 +++ openspec/changes/moss-eval-suite/tasks.md | 59 + openspec/config.yaml | 20 + package-lock.json | 1289 ++++++++++++++++- .../skills/rdk-model-zoo/SKILL.md | 115 +- .../skills/rdk-source-map/SKILL.md | 47 +- .../src/observability/ab-testing.ts | 165 +++ .../moss-agent/src/observability/index.ts | 15 - .../src/provider/model-complexity-router.ts | 323 +++++ .../moss-agent/src/tools/backup-manager.ts | 2 +- packages/moss-agent/src/tools/builtin.ts | 8 + packages/moss-agent/src/tools/file-tools.ts | 13 +- packages/moss-agent/src/tools/git-tools.ts | 304 ++++ .../moss-agent/src/tools/knowledge-search.ts | 230 +++ packages/moss-agent/src/tools/patch-tool.ts | 11 + packages/moss-agent/src/tools/undo-tool.ts | 55 + .../test/knowledge-search-chain.spec.mjs | 384 +++++ 37 files changed, 5544 insertions(+), 94 deletions(-) create mode 100644 .claude/commands/opsx/apply.md create mode 100644 .claude/commands/opsx/archive.md create mode 100644 .claude/commands/opsx/explore.md create mode 100644 .claude/commands/opsx/propose.md create mode 100644 .claude/commands/opsx/sync.md create mode 100644 .claude/skills/openspec-apply-change/SKILL.md create mode 100644 .claude/skills/openspec-archive-change/SKILL.md create mode 100644 .claude/skills/openspec-explore/SKILL.md create mode 100644 .claude/skills/openspec-propose/SKILL.md create mode 100644 .claude/skills/openspec-sync-specs/SKILL.md create mode 100644 CHANGES.md create mode 100644 docs/superpowers/specs/2026-07-10-knowledge-search-design.md create mode 100644 "moss\345\274\200\345\217\221\346\226\271\346\241\210.md" create mode 100644 openspec/changes/moss-eval-suite/.openspec.yaml create mode 100644 openspec/changes/moss-eval-suite/design.md create mode 100644 openspec/changes/moss-eval-suite/proposal.md create mode 100644 openspec/changes/moss-eval-suite/specs/eval-adversarial-tests/spec.md create mode 100644 openspec/changes/moss-eval-suite/specs/eval-cli-runner/spec.md create mode 100644 openspec/changes/moss-eval-suite/specs/eval-regression-tests/spec.md create mode 100644 openspec/changes/moss-eval-suite/specs/eval-scenario-e2e-tests/spec.md create mode 100644 openspec/changes/moss-eval-suite/specs/eval-tool-unit-tests/spec.md create mode 100644 openspec/changes/moss-eval-suite/tasks.md create mode 100644 openspec/config.yaml create mode 100644 packages/moss-agent/src/observability/ab-testing.ts create mode 100644 packages/moss-agent/src/provider/model-complexity-router.ts create mode 100644 packages/moss-agent/src/tools/git-tools.ts create mode 100644 packages/moss-agent/src/tools/knowledge-search.ts create mode 100644 packages/moss-agent/src/tools/undo-tool.ts create mode 100644 packages/moss-agent/test/knowledge-search-chain.spec.mjs diff --git a/.claude/commands/opsx/apply.md b/.claude/commands/opsx/apply.md new file mode 100644 index 00000000..f540757f --- /dev/null +++ b/.claude/commands/opsx/apply.md @@ -0,0 +1,155 @@ +--- +name: "OPSX: Apply" +description: Implement tasks from an OpenSpec change (Experimental) +category: Workflow +tags: [workflow, artifacts, experimental] +--- + +Implement tasks from an OpenSpec change. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select + + Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema) + - Progress (total, complete, remaining) + - Task list with status + - Dynamic instruction based on current state + + **Handle states:** + - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue` + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + +4. **Read context files** + + Read every file path listed under `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Mark task complete in the tasks file: `- [ ]` → `- [x]` + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! You can archive this change with `/opsx:archive`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.