@guanlin4924/devflow-pi 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
@@ -108,6 +108,7 @@ const DEVFLOW_CHINESE_LANGUAGE_POLICY = `
108
108
  Devflow 语言策略(强制):
109
109
  - 默认使用简体中文与用户沟通。
110
110
  - 需求、计划、Evidence Pack、Execution Contract、审查报告、验证报告和阻塞原因必须使用简体中文。
111
+ - 对用户可见的思考、推理摘要和阶段性进度必须使用简体中文;不要展示英文推理草稿来代替中文结论。
111
112
  - 文件名、类名、函数名、接口路径、命令、代码、日志原文和模型/Agent 名称保持原样,不翻译标识符。
112
113
  - 推理时先区分“事实、推断、未知、待用户确认”,不要用英文长篇解释替代证据。
113
114
  - 如果引用英文源码或工具输出,保留原文并紧跟中文解释。
@@ -10,11 +10,13 @@ tools:
10
10
  - edit
11
11
  - write
12
12
  model: "@task"
13
- thinkingLevel: high
13
+ thinkingLevel: medium
14
14
  autoloadSkills:
15
15
  - workflow-execute-phase
16
16
  ---
17
17
 
18
- Implement only the assigned plan slice. Read the relevant project rules, source, tests, and approved design artifacts before editing. Preserve unrelated work and report deviations instead of silently expanding scope.
18
+ Implement only the assigned plan slice. Do not re-plan the feature, compare alternatives, or explore unrelated modules. Read only the approved files, their immediate dependencies, and the focused tests needed for this slice. Preserve unrelated work and report deviations instead of silently expanding scope.
19
19
 
20
- Run focused validation and return changed files, commands, results, residual risks, and any blocked dependency. Never edit `.planning/STATE.md`, `.planning/artifacts/index.yaml`, workflow definitions, or checkpoint decisions; the parent session owns those through Devflow tools.
20
+ If the execution contract is missing, ambiguous, or contradicted by the code, stop before editing and return `STATUS: escalate` with the conflicting evidence. Run the smallest focused validation once; report failures rather than repeatedly trying speculative fixes.
21
+
22
+ Return `STATUS`, changed files, validation evidence, and one escalation/blocker if needed. Never edit `.planning/STATE.md`, `.planning/artifacts/index.yaml`, workflow definitions, or checkpoint decisions; the parent session owns those through Devflow tools.
@@ -1,27 +1,28 @@
1
1
  ---
2
2
  name: project-scout
3
- description: Read-only project explorer that locates implementation evidence, architecture boundaries, and affected files without proposing speculative changes.
3
+ description: Low-cost read-only evidence collector for bounded code-location questions; escalates when the question needs architecture or product judgment.
4
4
  tools:
5
5
  - read
6
6
  - grep
7
7
  - find
8
- - lsp
9
8
  - devflow_code_index
10
9
  model: "@smol"
11
- thinkingLevel: medium
10
+ thinkingLevel: low
12
11
  autoloadSkills:
13
12
  - code-index-query
14
- - workflow-code-index
15
13
  ---
16
14
 
17
- Act as a read-only evidence collector. Start from project indexes when they are fresh, then verify every material claim against LSP, search results, and source code. An index is an accelerator, never an authority.
15
+ Act as a bounded read-only evidence collector, not a planner. Use the code index first, then make at most three targeted reads or searches needed to answer the assigned question. Do not inspect adjacent systems merely to be thorough.
18
16
 
19
- Return:
17
+ If the answer needs architecture tradeoffs, requirements interpretation, a cross-module decision, or evidence remains insufficient after the bounded search, stop and return `STATUS: escalate`. Do not guess or continue exploring.
20
18
 
21
- 1. relevant entry points and symbols with paths;
22
- 2. cross-layer call paths;
23
- 3. conventions and tests that constrain the change;
24
- 4. unknowns or stale evidence;
25
- 5. the smallest defensible affected-file set.
19
+ Return exactly:
20
+
21
+ ```text
22
+ STATUS: done | escalate | blocked
23
+ EVIDENCE: up to 5 path/symbol facts
24
+ ANSWER: concise answer or smallest affected-file set
25
+ NEXT: only the required escalation or blocker; otherwise none
26
+ ```
26
27
 
27
28
  Do not edit files, mutate workflow state, or widen the task beyond the supplied question.
@@ -1,6 +1,9 @@
1
- import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionUIContext } from "@oh-my-pi/pi-coding-agent";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
2
4
 
3
- import { aggregateUsageReport, renderUsageReport, type UsageRange } from "./usage-stats.ts";
5
+ import { devflowHome } from "./paths.ts";
6
+ import { aggregateUsageReport, renderUsageHtml, renderUsageReport, type UsageRange } from "./usage-stats.ts";
4
7
 
5
8
  export interface PlatformExtensionOptions {
6
9
  sessionRoot: string;
@@ -8,7 +11,8 @@ export interface PlatformExtensionOptions {
8
11
  projectId: string;
9
12
  }
10
13
 
11
- type UsageInput = UsageRange & { source?: "project" | "omp"; format?: "table" | "json" };
14
+ type UsageFormat = "table" | "json" | "html";
15
+ type UsageInput = UsageRange & { source?: "project" | "omp"; format?: UsageFormat };
12
16
 
13
17
  function commandUsageInput(args: string): UsageInput {
14
18
  const tokens = args.trim().split(/\s+/).filter(Boolean);
@@ -22,9 +26,23 @@ function commandUsageInput(args: string): UsageInput {
22
26
  session: read("--session"),
23
27
  from: read("--from"),
24
28
  to: read("--to"),
29
+ models: read("--model")?.split(",").map(value => value.trim()).filter(Boolean),
30
+ format: (read("--format") as UsageFormat | undefined) ?? "table",
25
31
  };
26
32
  }
27
33
 
34
+ function localDate(offset = "+08:00"): string {
35
+ const formatter = new Intl.DateTimeFormat("en-CA", { timeZone: offset === "+08:00" ? "Asia/Shanghai" : "UTC", year: "numeric", month: "2-digit", day: "2-digit" });
36
+ const parts = Object.fromEntries(formatter.formatToParts(new Date()).filter(part => part.type !== "literal").map(part => [part.type, part.value]));
37
+ return `${parts.year}-${parts.month}-${parts.day}`;
38
+ }
39
+
40
+ function daysAgo(days: number): string {
41
+ const date = new Date();
42
+ date.setDate(date.getDate() - days);
43
+ return date.toISOString().slice(0, 10);
44
+ }
45
+
28
46
  export function createPlatformExtension(options: PlatformExtensionOptions) {
29
47
  return function devflowPlatform(pi: ExtensionAPI) {
30
48
  const { z } = pi.zod;
@@ -34,7 +52,8 @@ export function createPlatformExtension(options: PlatformExtensionOptions) {
34
52
  session: z.string().optional(),
35
53
  from: z.string().optional(),
36
54
  to: z.string().optional(),
37
- format: z.enum(["table", "json"]).default("table"),
55
+ models: z.array(z.string()).optional(),
56
+ format: z.enum(["table", "json", "html"]).default("table"),
38
57
  });
39
58
  const usageTool = {
40
59
  label: "Devflow Usage Stats",
@@ -48,13 +67,13 @@ export function createPlatformExtension(options: PlatformExtensionOptions) {
48
67
  const report = await aggregateUsageReport(
49
68
  source === "omp" ? options.ompSessionRoot : options.sessionRoot,
50
69
  source,
51
- { session: input.session, from: input.from, to: input.to },
70
+ { session: input.session, from: input.from, to: input.to, models: input.models },
52
71
  );
53
72
  return {
54
73
  content: [
55
74
  {
56
75
  type: "text" as const,
57
- text: input.format === "json" ? JSON.stringify({ projectId: options.projectId, ...report }, null, 2) : renderUsageReport(report),
76
+ text: input.format === "json" ? JSON.stringify({ projectId: options.projectId, ...report }, null, 2) : input.format === "html" ? renderUsageHtml(report) : renderUsageReport(report),
58
77
  },
59
78
  ],
60
79
  details: { projectId: options.projectId, ...report },
@@ -67,17 +86,61 @@ export function createPlatformExtension(options: PlatformExtensionOptions) {
67
86
  ...usageTool,
68
87
  });
69
88
  }
89
+ async function pickUsageInput(ctx: { ui: ExtensionUIContext }): Promise<UsageInput | undefined> {
90
+ const sourceChoice = await ctx.ui.select("使用统计:数据范围", [
91
+ { label: "当前 Devflow 项目", description: "只读取当前项目会话(推荐)" },
92
+ { label: "OMP 历史", description: "读取 ~/.omp/agent/sessions" },
93
+ ]);
94
+ if (!sourceChoice) return undefined;
95
+ const source = sourceChoice === "OMP 历史" ? "omp" : "project" as const;
96
+ const root = source === "omp" ? options.ompSessionRoot : options.sessionRoot;
97
+ const overview = await aggregateUsageReport(root, source);
98
+ const sessionChoice = await ctx.ui.select("使用统计:会话", ["全部会话", ...Object.keys(overview.perSession)]);
99
+ if (!sessionChoice) return undefined;
100
+ const modelChoice = await ctx.ui.select("使用统计:模型", ["全部模型", ...Object.keys(overview.perModel)]);
101
+ if (!modelChoice) return undefined;
102
+ const period = await ctx.ui.select("使用统计:时间范围", ["全部时间", "今天", "最近 7 天", "最近 30 天", "自定义日期"]);
103
+ if (!period) return undefined;
104
+ let from: string | undefined;
105
+ let to: string | undefined;
106
+ if (period === "今天") from = to = localDate();
107
+ if (period === "最近 7 天") { from = daysAgo(6); to = localDate(); }
108
+ if (period === "最近 30 天") { from = daysAgo(29); to = localDate(); }
109
+ if (period === "自定义日期") {
110
+ from = await ctx.ui.input("使用统计:开始日期", "YYYY-MM-DD(留空表示不限)");
111
+ if (from === undefined) return undefined;
112
+ to = await ctx.ui.input("使用统计:结束日期", "YYYY-MM-DD(留空表示不限)");
113
+ if (to === undefined) return undefined;
114
+ }
115
+ const output = await ctx.ui.select("使用统计:输出方式", [
116
+ { label: "终端表格", description: "在当前终端显示" },
117
+ { label: "HTML 报告", description: "生成可在浏览器打开的单文件报告" },
118
+ { label: "JSON", description: "用于自动化处理" },
119
+ ]);
120
+ if (!output) return undefined;
121
+ return { source, session: sessionChoice === "全部会话" ? "all" : sessionChoice, models: modelChoice === "全部模型" ? undefined : [modelChoice], from: from || undefined, to: to || undefined, format: output === "HTML 报告" ? "html" : output === "JSON" ? "json" : "table" };
122
+ }
123
+
124
+ async function renderCommand(input: UsageInput, ctx: { ui: { notify(message: string, type?: "info" | "warning" | "error"): void } }) {
125
+ const source = input.source ?? "project";
126
+ const report = await aggregateUsageReport(source === "omp" ? options.ompSessionRoot : options.sessionRoot, source, input);
127
+ if (input.format === "html") {
128
+ const reportDir = path.join(devflowHome(), "reports", options.projectId);
129
+ const destination = path.join(reportDir, `usage-${new Date().toISOString().replaceAll(":", "-")}.html`);
130
+ await mkdir(reportDir, { recursive: true });
131
+ await writeFile(destination, renderUsageHtml(report), "utf8");
132
+ ctx.ui.notify(`已生成 HTML 使用报告:${destination}`, "info");
133
+ return;
134
+ }
135
+ ctx.ui.notify(input.format === "json" ? JSON.stringify({ projectId: options.projectId, ...report }, null, 2).slice(0, 12000) : renderUsageReport(report).slice(0, 12000), "info");
136
+ }
137
+
70
138
  pi.registerCommand("usage-stats", {
71
- description: "Show token/cost usage. Default is this project; pass `omp` or --source omp for explicit OMP history.",
139
+ description: "交互式查看 token/成本:范围、会话、模型、时间与 HTML 报告;也支持 --source/--session/--model/--format 参数。",
72
140
  handler: async (args, ctx) => {
73
- const input = commandUsageInput(args);
74
- const source = input.source ?? "project";
75
- const report = await aggregateUsageReport(
76
- source === "omp" ? options.ompSessionRoot : options.sessionRoot,
77
- source,
78
- { session: input.session, from: input.from, to: input.to },
79
- );
80
- ctx.ui.notify(renderUsageReport(report).slice(0, 12000), "info");
141
+ const input = args.trim() ? commandUsageInput(args) : await pickUsageInput(ctx);
142
+ if (!input) return;
143
+ await renderCommand(input, ctx);
81
144
  },
82
145
  });
83
146
  pi.registerCommand("devflow-usage", {
package/src/runtime.ts CHANGED
@@ -35,6 +35,12 @@ const MANAGED_SETTINGS: Record<string, unknown> = {
35
35
  "task.maxConcurrency": 1,
36
36
  "task.maxRecursionDepth": 1,
37
37
  "task.enableLsp": true,
38
+ // Keep cheap workers bounded by role prompts. OMP's loop guards remain
39
+ // enabled, while these switches prevent accidental high-effort keywords from
40
+ // widening a normal Devflow turn into an unbounded orchestration request.
41
+ "magicKeywords.ultrathink": false,
42
+ "magicKeywords.orchestrate": false,
43
+ "magicKeywords.workflow": false,
38
44
  "task.agentModelOverrides": {
39
45
  "project-scout": "@smol",
40
46
  "requirements-auditor": "@slow",
@@ -19,12 +19,19 @@ export interface UsageReport {
19
19
  totals: UsageTotals;
20
20
  perModel: Record<string, UsageTotals>;
21
21
  perSession: Record<string, UsageTotals>;
22
+ /**
23
+ * Message-linked attribution. A model reply that reads more than one Skill
24
+ * has its usage divided equally between those Skills, so this table always
25
+ * sums to the report total instead of double-counting a reply.
26
+ */
27
+ perSkill: Record<string, UsageTotals>;
22
28
  }
23
29
 
24
30
  export interface UsageRange {
25
31
  from?: string;
26
32
  to?: string;
27
33
  session?: string;
34
+ models?: string[];
28
35
  timezoneOffset?: string;
29
36
  }
30
37
 
@@ -61,6 +68,17 @@ function addUsage(target: UsageTotals, usage: Record<string, unknown>): void {
61
68
  target.assistantMessages += 1;
62
69
  }
63
70
 
71
+ function addUsageShare(target: UsageTotals, usage: Record<string, unknown>, divisor: number): void {
72
+ const share = Math.max(1, divisor);
73
+ target.input += finiteNumber(usage.input) / share;
74
+ target.output += finiteNumber(usage.output) / share;
75
+ target.cacheRead += finiteNumber(usage.cacheRead) / share;
76
+ target.cacheWrite += finiteNumber(usage.cacheWrite) / share;
77
+ target.totalTokens += finiteNumber(usage.totalTokens) / share;
78
+ target.cost += usageCost(usage.cost) / share;
79
+ target.assistantMessages += 1 / share;
80
+ }
81
+
64
82
  function nextDate(value: string): string {
65
83
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
66
84
  if (!match) return value;
@@ -107,6 +125,24 @@ function modelName(entry: Record<string, unknown>, current: string): string {
107
125
  return current;
108
126
  }
109
127
 
128
+ function messageSkills(message: Record<string, unknown>): string[] {
129
+ const content = message.content;
130
+ if (!Array.isArray(content)) return [];
131
+ const skills = new Set<string>();
132
+ for (const block of content) {
133
+ if (!block || typeof block !== "object") continue;
134
+ const value = block as Record<string, unknown>;
135
+ const argumentsValue = value.arguments;
136
+ const text = typeof value.text === "string" ? value.text : "";
137
+ const pathValue = argumentsValue && typeof argumentsValue === "object" ? (argumentsValue as Record<string, unknown>).path : undefined;
138
+ const candidates = [text, typeof pathValue === "string" ? pathValue : ""];
139
+ for (const candidate of candidates) {
140
+ for (const match of candidate.matchAll(/skill:\/\/([^/\s?#]+)/g)) skills.add(match[1]);
141
+ }
142
+ }
143
+ return [...skills].sort();
144
+ }
145
+
110
146
  export async function aggregateUsageReport(
111
147
  sessionRoot: string,
112
148
  source: "project" | "omp" = "project",
@@ -115,6 +151,7 @@ export async function aggregateUsageReport(
115
151
  const totals = emptyUsage();
116
152
  const perModel: Record<string, UsageTotals> = {};
117
153
  const perSession: Record<string, UsageTotals> = {};
154
+ const perSkill: Record<string, UsageTotals> = {};
118
155
  const files = await jsonlFiles(sessionRoot);
119
156
  totals.sessionFiles = files.length;
120
157
 
@@ -143,10 +180,14 @@ export async function aggregateUsageReport(
143
180
  const usage = message.usage as Record<string, unknown> | undefined;
144
181
  if (!usage) continue;
145
182
  const model = currentModel;
183
+ if (range.models?.length && !range.models.includes(model)) continue;
146
184
  const modelTotals = (perModel[model] ??= emptyUsage());
147
185
  addUsage(totals, usage);
148
186
  addUsage(sessionTotals, usage);
149
187
  addUsage(modelTotals, usage);
188
+ const skills = messageSkills(message);
189
+ const attribution = skills.length ? skills : ["未归因(此回复未直接读取 Skill)"];
190
+ for (const skill of attribution) addUsageShare((perSkill[skill] ??= emptyUsage()), usage, attribution.length);
150
191
  }
151
192
  }
152
193
 
@@ -162,6 +203,7 @@ export async function aggregateUsageReport(
162
203
  totals,
163
204
  perModel,
164
205
  perSession,
206
+ perSkill,
165
207
  };
166
208
  }
167
209
 
@@ -200,6 +242,23 @@ export function renderUsageReport(report: UsageReport): string {
200
242
  `sessions: ${totals.sessionFiles} | assistant requests: ${totals.assistantMessages} | tokens: ${totals.totalTokens.toLocaleString("en-US")} | cost: $${totals.cost.toFixed(6)}`,
201
243
  ...renderRows("By model", report.perModel),
202
244
  ...renderRows("By session", report.perSession),
203
- "Note: token/cost totals use assistant message usage directly. Tool-call cost allocation is intentionally not estimated.",
245
+ ...renderRows("By Skill(按读取 Skill 的回复归因;多 Skill 回复均分)", report.perSkill),
246
+ "Note: token/cost totals use assistant message usage directly. Skill attribution is message-linked, not provider-level per-Skill billing.",
204
247
  ].join("\n");
205
248
  }
249
+
250
+ function html(value: string): string {
251
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
252
+ }
253
+
254
+ function renderHtmlRows(rows: Record<string, UsageTotals>): string {
255
+ return Object.entries(rows)
256
+ .sort(([, left], [, right]) => right.totalTokens - left.totalTokens || right.cost - left.cost)
257
+ .map(([name, value]) => `<tr><td>${html(name)}</td><td>${value.assistantMessages.toFixed(2)}</td><td>${Math.round(value.totalTokens).toLocaleString("en-US")}</td><td>$${value.cost.toFixed(6)}</td></tr>`)
258
+ .join("") || "<tr><td colspan=\"4\">无数据</td></tr>";
259
+ }
260
+
261
+ export function renderUsageHtml(report: UsageReport): string {
262
+ const table = (title: string, rows: Record<string, UsageTotals>) => `<section><h2>${html(title)}</h2><table><thead><tr><th>名称</th><th>请求</th><th>Token</th><th>成本</th></tr></thead><tbody>${renderHtmlRows(rows)}</tbody></table></section>`;
263
+ return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>Devflow 使用统计</title><style>body{max-width:1100px;margin:32px auto;padding:0 20px;background:#111827;color:#e5e7eb;font:14px system-ui}h1{margin-bottom:4px}.muted{color:#9ca3af}section{margin-top:28px}table{width:100%;border-collapse:collapse;background:#182235}th,td{padding:10px;text-align:left;border-bottom:1px solid #334155}th{color:#93c5fd}td:nth-child(n+2){font-variant-numeric:tabular-nums}</style><h1>Devflow 使用统计</h1><p class="muted">${html(`来源:${report.source} | 会话:${report.filter.session} | ${report.filter.from ?? "全部时间"} 至 ${report.filter.to ?? "全部时间"}(${report.filter.timezoneOffset})`)}</p><section><h2>总计</h2><p>${report.totals.sessionFiles} 个会话 | ${report.totals.assistantMessages} 次请求 | ${report.totals.totalTokens.toLocaleString("en-US")} Token | $${report.totals.cost.toFixed(6)}</p></section>${table("按模型", report.perModel)}${table("按会话", report.perSession)}${table("按 Skill(多 Skill 回复均分)", report.perSkill)}<p class="muted">Skill 成本为回复级归因,不是供应商提供的逐 Skill 账单。</p></html>`;
264
+ }