pi-skill-evolve 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +81 -2
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/after.md +10 -0
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/fixture.json +6 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/after.md +9 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/fixture.json +6 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/after.md +8 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/fixture.json +6 -0
- package/extensions/skill-evolve/index.ts +300 -0
- package/extensions/skill-evolve/lib/apply-gates.ts +204 -0
- package/extensions/skill-evolve/lib/apply-targets.ts +90 -0
- package/extensions/skill-evolve/lib/apply.ts +141 -0
- package/extensions/skill-evolve/lib/attribution.ts +138 -0
- package/extensions/skill-evolve/lib/audit.ts +221 -0
- package/extensions/skill-evolve/lib/baseline.ts +176 -0
- package/extensions/skill-evolve/lib/child-env.ts +10 -0
- package/extensions/skill-evolve/lib/classify-action.ts +161 -0
- package/extensions/skill-evolve/lib/command.ts +100 -0
- package/extensions/skill-evolve/lib/config-command.ts +38 -0
- package/extensions/skill-evolve/lib/config.ts +194 -0
- package/extensions/skill-evolve/lib/detect.ts +62 -0
- package/extensions/skill-evolve/lib/diff-apply.ts +90 -0
- package/extensions/skill-evolve/lib/diff-gen.ts +78 -0
- package/extensions/skill-evolve/lib/dispatch.ts +300 -0
- package/extensions/skill-evolve/lib/evolution-apply.ts +265 -0
- package/extensions/skill-evolve/lib/evolution-model.ts +72 -0
- package/extensions/skill-evolve/lib/evolution-observe.ts +82 -0
- package/extensions/skill-evolve/lib/evolution-views.ts +89 -0
- package/extensions/skill-evolve/lib/evolution.ts +289 -0
- package/extensions/skill-evolve/lib/flags.ts +44 -0
- package/extensions/skill-evolve/lib/gate0.ts +78 -0
- package/extensions/skill-evolve/lib/hard-rules.ts +167 -0
- package/extensions/skill-evolve/lib/lock.ts +215 -0
- package/extensions/skill-evolve/lib/logfile.ts +52 -0
- package/extensions/skill-evolve/lib/mechanical-report.ts +95 -0
- package/extensions/skill-evolve/lib/mechanical.ts +136 -0
- package/extensions/skill-evolve/lib/metrics.ts +172 -0
- package/extensions/skill-evolve/lib/model-config.ts +95 -0
- package/extensions/skill-evolve/lib/notify.ts +132 -0
- package/extensions/skill-evolve/lib/panel.ts +174 -0
- package/extensions/skill-evolve/lib/pending.ts +34 -0
- package/extensions/skill-evolve/lib/proc.ts +65 -0
- package/extensions/skill-evolve/lib/prompt.ts +80 -0
- package/extensions/skill-evolve/lib/proposal.ts +227 -0
- package/extensions/skill-evolve/lib/prune-plan.ts +62 -0
- package/extensions/skill-evolve/lib/queue.ts +136 -0
- package/extensions/skill-evolve/lib/replay-fixture.ts +80 -0
- package/extensions/skill-evolve/lib/replay.ts +246 -0
- package/extensions/skill-evolve/lib/report.ts +181 -0
- package/extensions/skill-evolve/lib/review-post.ts +101 -0
- package/extensions/skill-evolve/lib/review-prompt.ts +82 -0
- package/extensions/skill-evolve/lib/review-record.ts +63 -0
- package/extensions/skill-evolve/lib/review-run.ts +131 -0
- package/extensions/skill-evolve/lib/review-spawn.ts +83 -0
- package/extensions/skill-evolve/lib/review-supervisor.ts +267 -0
- package/extensions/skill-evolve/lib/round.ts +116 -0
- package/extensions/skill-evolve/lib/run-watch.ts +34 -0
- package/extensions/skill-evolve/lib/runs.ts +208 -0
- package/extensions/skill-evolve/lib/selftest.ts +201 -0
- package/extensions/skill-evolve/lib/session-index.ts +145 -0
- package/extensions/skill-evolve/lib/shutdown-dispatch.ts +177 -0
- package/extensions/skill-evolve/lib/single-flight.ts +111 -0
- package/extensions/skill-evolve/lib/spawn-plan.ts +103 -0
- package/extensions/skill-evolve/lib/stale.ts +217 -0
- package/extensions/skill-evolve/lib/telemetry.ts +205 -0
- package/extensions/skill-evolve/lib/ui-gate.ts +34 -0
- package/extensions/skill-evolve/lib/web/actions.ts +299 -0
- package/extensions/skill-evolve/lib/web/activity.ts +57 -0
- package/extensions/skill-evolve/lib/web/applied.ts +64 -0
- package/extensions/skill-evolve/lib/web/approvals.ts +58 -0
- package/extensions/skill-evolve/lib/web/audit-log.ts +115 -0
- package/extensions/skill-evolve/lib/web/autostart.ts +177 -0
- package/extensions/skill-evolve/lib/web/data.ts +298 -0
- package/extensions/skill-evolve/lib/web/diff-lines.ts +141 -0
- package/extensions/skill-evolve/lib/web/filter.ts +203 -0
- package/extensions/skill-evolve/lib/web/http.ts +68 -0
- package/extensions/skill-evolve/lib/web/json-read.ts +33 -0
- package/extensions/skill-evolve/lib/web/panel-pid.ts +99 -0
- package/extensions/skill-evolve/lib/web/render-controls.ts +156 -0
- package/extensions/skill-evolve/lib/web/render-rows.ts +252 -0
- package/extensions/skill-evolve/lib/web/render-run.ts +188 -0
- package/extensions/skill-evolve/lib/web/render-skills.ts +164 -0
- package/extensions/skill-evolve/lib/web/render.ts +136 -0
- package/extensions/skill-evolve/lib/web/reports.ts +160 -0
- package/extensions/skill-evolve/lib/web/run-detail.ts +234 -0
- package/extensions/skill-evolve/lib/web/run-path.ts +21 -0
- package/extensions/skill-evolve/lib/web/server.ts +268 -0
- package/extensions/skill-evolve/lib/web/shell.ts +87 -0
- package/extensions/skill-evolve/lib/web/skills.ts +202 -0
- package/extensions/skill-evolve/lib/web/style.ts +107 -0
- package/extensions/skill-evolve/lib/web/text.ts +52 -0
- package/extensions/skill-evolve/lib/web/trash.ts +36 -0
- package/extensions/skill-evolve/lib/weekly.ts +285 -0
- package/extensions/skill-evolve/prompts//345/244/215/347/233/230.md +172 -0
- package/extensions/skill-evolve/scripts/replay.ts +93 -0
- package/extensions/skill-evolve/test/apply.test.ts +278 -0
- package/extensions/skill-evolve/test/approve.test.ts +248 -0
- package/extensions/skill-evolve/test/attribution.test.ts +133 -0
- package/extensions/skill-evolve/test/audit.test.ts +151 -0
- package/extensions/skill-evolve/test/baseline.test.ts +132 -0
- package/extensions/skill-evolve/test/classify-action.test.ts +133 -0
- package/extensions/skill-evolve/test/command.test.ts +267 -0
- package/extensions/skill-evolve/test/config-command.test.ts +73 -0
- package/extensions/skill-evolve/test/config.test.ts +154 -0
- package/extensions/skill-evolve/test/detect.test.ts +71 -0
- package/extensions/skill-evolve/test/diff-apply.test.ts +46 -0
- package/extensions/skill-evolve/test/dispatch-busy.test.ts +223 -0
- package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +88 -0
- package/extensions/skill-evolve/test/dispatch.test.ts +306 -0
- package/extensions/skill-evolve/test/evolution.test.ts +378 -0
- package/extensions/skill-evolve/test/fake-review-spawn.ts +60 -0
- package/extensions/skill-evolve/test/hard-rules.test.ts +116 -0
- package/extensions/skill-evolve/test/lock.test.ts +138 -0
- package/extensions/skill-evolve/test/logfile.test.ts +85 -0
- package/extensions/skill-evolve/test/mechanical-wiring.test.ts +217 -0
- package/extensions/skill-evolve/test/metrics.test.ts +129 -0
- package/extensions/skill-evolve/test/model-config.test.ts +84 -0
- package/extensions/skill-evolve/test/notify.test.ts +133 -0
- package/extensions/skill-evolve/test/observe.test.ts +100 -0
- package/extensions/skill-evolve/test/panel-pid.test.ts +84 -0
- package/extensions/skill-evolve/test/panel.test.ts +115 -0
- package/extensions/skill-evolve/test/proposal.test.ts +159 -0
- package/extensions/skill-evolve/test/queue.test.ts +110 -0
- package/extensions/skill-evolve/test/reject.test.ts +134 -0
- package/extensions/skill-evolve/test/replay.test.ts +111 -0
- package/extensions/skill-evolve/test/report.test.ts +66 -0
- package/extensions/skill-evolve/test/review-post.test.ts +92 -0
- package/extensions/skill-evolve/test/review-prompt.test.ts +105 -0
- package/extensions/skill-evolve/test/review-spawn.test.ts +81 -0
- package/extensions/skill-evolve/test/review-supervisor.test.ts +243 -0
- package/extensions/skill-evolve/test/selftest.test.ts +113 -0
- package/extensions/skill-evolve/test/session-index.test.ts +128 -0
- package/extensions/skill-evolve/test/single-flight.test.ts +122 -0
- package/extensions/skill-evolve/test/stale.test.ts +272 -0
- package/extensions/skill-evolve/test/telemetry.test.ts +145 -0
- package/extensions/skill-evolve/test/ui-gate.test.ts +77 -0
- package/extensions/skill-evolve/test/web-actions.test.ts +262 -0
- package/extensions/skill-evolve/test/web-applied.test.ts +100 -0
- package/extensions/skill-evolve/test/web-audit.test.ts +87 -0
- package/extensions/skill-evolve/test/web-autostart.test.ts +268 -0
- package/extensions/skill-evolve/test/web-data.test.ts +420 -0
- package/extensions/skill-evolve/test/web-diff-lines.test.ts +155 -0
- package/extensions/skill-evolve/test/web-filter.test.ts +212 -0
- package/extensions/skill-evolve/test/web-panel-pid.test.ts +97 -0
- package/extensions/skill-evolve/test/web-render-run.test.ts +132 -0
- package/extensions/skill-evolve/test/web-render-skills.test.ts +134 -0
- package/extensions/skill-evolve/test/web-render.test.ts +300 -0
- package/extensions/skill-evolve/test/web-run-detail.test.ts +256 -0
- package/extensions/skill-evolve/test/web-server-views.test.ts +113 -0
- package/extensions/skill-evolve/test/web-server.test.ts +465 -0
- package/extensions/skill-evolve/test/web-shell.test.ts +67 -0
- package/extensions/skill-evolve/test/web-skills.test.ts +239 -0
- package/extensions/skill-evolve/test/weekly.test.ts +123 -0
- package/package.json +34 -4
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 技能管理页(`?view=skills`)与技能详情页(`?view=skill&name=...`)的渲染。
|
|
3
|
+
*
|
|
4
|
+
* 一行一个技能,字段顺序按「先看规模、再看效果、最后看该不该动手」:
|
|
5
|
+
* 技能名 · 曝光/加载/加载率 · 最近一次相关 run · 三个状态标签。
|
|
6
|
+
*
|
|
7
|
+
* 空态分两种,绝不含糊成一句「暂无数据」:
|
|
8
|
+
* - 采集关着 → 「没有遥测数据(采集关着)」,并指路去开(页面顶部控制条就是开关);
|
|
9
|
+
* - 采集开着但窗口内没事件 → 「最近 N 天没有遥测事件」,说明是没跑过还是窗口太窄。
|
|
10
|
+
*/
|
|
11
|
+
import type { WebState } from "./data.ts";
|
|
12
|
+
import type { WebControls } from "./activity.ts";
|
|
13
|
+
import { renderControlBar } from "./render-controls.ts";
|
|
14
|
+
import type { SkillDetail, SkillRow, SkillsView } from "./skills.ts";
|
|
15
|
+
import { SKILL_DAYS } from "./skills.ts";
|
|
16
|
+
import { shellPage } from "./shell.ts";
|
|
17
|
+
import { escapeHtml, formatStamp, shortRunId } from "./text.ts";
|
|
18
|
+
|
|
19
|
+
/** 加载率:没曝光时写「—」而不是 0%(0% 会被当成「一百次曝光一次没加载」) */
|
|
20
|
+
export function rateText(rate: number | null): string {
|
|
21
|
+
return rate === null ? "—" : `${(rate * 100).toFixed(0)}%`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 采集关着时曝光/加载写「—」:0 会被读成「这技能没人用」,实际是「没采到」 */
|
|
25
|
+
function countText(unknown: boolean, value: number): string {
|
|
26
|
+
return unknown ? "—" : String(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 三个状态标签各自一色,且**不合并**:每个标签对应一个不同的处置动作 */
|
|
30
|
+
export function skillTags(row: SkillRow): string {
|
|
31
|
+
const tags: string[] = [];
|
|
32
|
+
if (row.hasPendingProposal) tags.push(`<span class="tag warn">有待批提案</span>`);
|
|
33
|
+
if (row.hasBaseline) tags.push(`<span class="tag fail">存量违规</span>`);
|
|
34
|
+
if (row.missingCursorStub) tags.push(`<span class="tag">缺 .cursor stub</span>`);
|
|
35
|
+
return tags.length > 0 ? tags.join(" ") : `<span class="hint">—</span>`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function skillHref(name: string, days: number): string {
|
|
39
|
+
return `/?view=skill&name=${encodeURIComponent(name)}&days=${days}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function runHref(runId: string): string {
|
|
43
|
+
return `/?view=run&runId=${encodeURIComponent(runId)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 窗口切换:只给白名单里的三个值,当前那个不再是链接 */
|
|
47
|
+
function dayLinks(current: number): string {
|
|
48
|
+
const parts = SKILL_DAYS.map((days) =>
|
|
49
|
+
days === current
|
|
50
|
+
? `<span class="on">近 ${days} 天</span>`
|
|
51
|
+
: `<a href="${escapeHtml(`/?view=skills&days=${days}`)}">近 ${days} 天</a>`,
|
|
52
|
+
);
|
|
53
|
+
return `<p class="days">统计窗口:${parts.join(" · ")}</p>`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 遥测空态的两种说法(采集关着 / 开着但没数据) */
|
|
57
|
+
function telemetryNote(view: SkillsView): string {
|
|
58
|
+
if (view.events > 0) return "";
|
|
59
|
+
if (!view.telemetryOn) {
|
|
60
|
+
return `<p class="warn">没有遥测数据(采集关着):曝光与加载都是 0 是因为没采集,不是技能没被用。
|
|
61
|
+
用顶部控制条打开采集后会开始记录。</p>`;
|
|
62
|
+
}
|
|
63
|
+
return `<p class="hint">最近 ${view.days} 天没有遥测事件:采集开着,但这段时间没有会话记录。</p>`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RenderSkillsOptions {
|
|
67
|
+
/** 从哪一页链进来的(详情页的返回链接) */
|
|
68
|
+
back?: { view: string; label: string };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 列表页要能就地开关采集(曝光/加载就是它采的),所以把控制条数据一起传进来 */
|
|
72
|
+
export interface SkillsPageControls {
|
|
73
|
+
csrf?: string;
|
|
74
|
+
canWrite?: boolean;
|
|
75
|
+
controls?: WebControls | null;
|
|
76
|
+
now?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function renderSkillsPage(view: SkillsView, state: WebState, options: SkillsPageControls = {}): string {
|
|
80
|
+
const top = options.controls
|
|
81
|
+
? `${renderControlBar(options.controls, {
|
|
82
|
+
view: "skills",
|
|
83
|
+
now: options.now ?? Date.now(),
|
|
84
|
+
csrf: options.csrf,
|
|
85
|
+
canWrite: options.canWrite,
|
|
86
|
+
})}\n`
|
|
87
|
+
: "";
|
|
88
|
+
const head = `<thead><tr><th>技能</th><th>曝光</th><th>加载</th><th>加载率</th><th>最近一次相关 run</th><th>状态</th></tr></thead>`;
|
|
89
|
+
// 采集关着(且确实一个事件都没有)时曝光/加载是「不知道」而不是 0:写 0 会被读成「这技能没人用」
|
|
90
|
+
const unknown = !view.telemetryOn && view.events === 0;
|
|
91
|
+
const rows = view.rows.map((row) => {
|
|
92
|
+
const last =
|
|
93
|
+
row.lastRunId === null
|
|
94
|
+
? `<span class="hint">—</span>`
|
|
95
|
+
: `<a href="${escapeHtml(runHref(row.lastRunId))}">${escapeHtml(shortRunId(row.lastRunId))}</a> <span class="hint">${escapeHtml(formatStamp(row.lastRunAt))}</span>`;
|
|
96
|
+
return `<tr><td><a href="${escapeHtml(skillHref(row.name, view.days))}">${escapeHtml(row.name)}</a></td><td>${countText(unknown, row.exposed)}</td><td>${countText(unknown, row.loaded)}</td><td>${rateText(row.rate)}</td><td>${last}</td><td>${skillTags(row)}</td></tr>`;
|
|
97
|
+
});
|
|
98
|
+
const body =
|
|
99
|
+
view.rows.length === 0
|
|
100
|
+
? `<p class="empty">.pi/skills 下没有技能目录:先确认工作区根路径对不对(面板从 .pi/evolution 往上两层推断)。</p>`
|
|
101
|
+
: `<table class="tbl">
|
|
102
|
+
${head}
|
|
103
|
+
<tbody>
|
|
104
|
+
${rows.join("\n")}
|
|
105
|
+
</tbody>
|
|
106
|
+
</table>`;
|
|
107
|
+
return shellPage({
|
|
108
|
+
state,
|
|
109
|
+
title: "技能管理",
|
|
110
|
+
active: "skills",
|
|
111
|
+
top,
|
|
112
|
+
body: `<main>
|
|
113
|
+
<h2>技能(${view.rows.length} 个,窗口 ${view.days} 天)</h2>
|
|
114
|
+
${dayLinks(view.days)}
|
|
115
|
+
${telemetryNote(view)}
|
|
116
|
+
${body}
|
|
117
|
+
</main>`,
|
|
118
|
+
foot: `只读:曝光 = 含该技能的 advertised 轮数(同一轮重复算一次,disabled 不计),加载 = loaded 事件数。
|
|
119
|
+
遥测原文在 <code>.pi/evolution/telemetry/</code>。`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 技能详情:统计一行 + 历史提案表(每个 run 一行,带 verdict 与 diff 链接) */
|
|
124
|
+
export function renderSkillPage(detail: SkillDetail, state: WebState, options: RenderSkillsOptions = {}): string {
|
|
125
|
+
const back = options.back ?? { view: "skills", label: "技能管理" };
|
|
126
|
+
const row = detail.row;
|
|
127
|
+
const unknown = !detail.telemetryOn && detail.events === 0;
|
|
128
|
+
const stats =
|
|
129
|
+
row === null
|
|
130
|
+
? `<p class="hint">${escapeHtml(detail.name)} 不在 .pi/skills 目录里(可能已改名或删除),下面只列历史提案。</p>`
|
|
131
|
+
: `<p class="kv-line">
|
|
132
|
+
曝光 <b>${countText(unknown, row.exposed)}</b> · 加载 <b>${countText(unknown, row.loaded)}</b> · 加载率 <b>${rateText(row.rate)}</b> · 窗口 ${detail.days} 天
|
|
133
|
+
</p>
|
|
134
|
+
<p class="kv-line">状态:${skillTags(row)}</p>`;
|
|
135
|
+
// 第一列表头点明「点进去就是 diff 视图」:用户要的 diff 就藏在这个 runId 后面
|
|
136
|
+
const head = `<thead><tr><th>run(点进看 diff)</th><th>状态</th><th>时间</th><th>提案</th><th>verdict</th><th>action</th></tr></thead>`;
|
|
137
|
+
const rows = detail.runs.map((run) => {
|
|
138
|
+
const delta = `${run.type} +${run.added}/-${run.removed}`;
|
|
139
|
+
return `<tr><td><a href="${escapeHtml(runHref(run.runId))}">${escapeHtml(shortRunId(run.runId))}</a></td><td>${escapeHtml(run.status)}</td><td>${escapeHtml(formatStamp(run.at))}</td><td>${escapeHtml(delta)}</td><td>${escapeHtml(run.verdict)}</td><td>${escapeHtml(run.action)}</td></tr>`;
|
|
140
|
+
});
|
|
141
|
+
const table =
|
|
142
|
+
detail.runs.length === 0
|
|
143
|
+
? `<p class="empty">还没有针对 ${escapeHtml(detail.name)} 的提案:runs/ 里没有 skill=${escapeHtml(detail.name)} 的 02-proposal.json。</p>`
|
|
144
|
+
: `<table class="tbl">
|
|
145
|
+
${head}
|
|
146
|
+
<tbody>
|
|
147
|
+
${rows.join("\n")}
|
|
148
|
+
</tbody>
|
|
149
|
+
</table>`;
|
|
150
|
+
return shellPage({
|
|
151
|
+
state,
|
|
152
|
+
title: `技能 ${detail.name}`,
|
|
153
|
+
active: "skills",
|
|
154
|
+
body: `<main>
|
|
155
|
+
<p class="hint"><a href="/?view=${escapeHtml(back.view)}">← 回${escapeHtml(back.label)}</a></p>
|
|
156
|
+
<h2>${escapeHtml(detail.name)}</h2>
|
|
157
|
+
${stats}
|
|
158
|
+
<h2>历史提案</h2>
|
|
159
|
+
${table}
|
|
160
|
+
</main>`,
|
|
161
|
+
foot: `只读:历史提案来自 runs/*/02-proposal.json,点 run 进详情页看 diff 与评审证据。`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web 面板的服务端渲染:纯字符串拼 HTML,不引模板库、不发任何外部请求、不依赖 JS。
|
|
3
|
+
*
|
|
4
|
+
* 这一版只负责**列表页**:控制条 → 横幅 → 导航 → 筛选 → 列表 → 分页 → 最近操作 → 页脚。
|
|
5
|
+
* 页面骨架(`<head>` / 状态栏 / 六个 tab / 页脚)在 ./shell.ts;
|
|
6
|
+
* 列表行在 ./render-rows.ts,控制条/筛选/分页/最近操作在 ./render-controls.ts,
|
|
7
|
+
* 技能管理页在 ./render-skills.ts,run 详情页在 ./render-run.ts ——
|
|
8
|
+
* 拆开是因为合在一起必然破 300 行,而拆开之后每段都能单独看。
|
|
9
|
+
*
|
|
10
|
+
* 渲染层不读盘:`controls`(开关与最后活动)与 `audit`(最近操作)都由服务端读好传进来。
|
|
11
|
+
* 拿不到开关状态时**不渲染控制条**,而不是猜一个「已开」——顶部写错状态比不写更糟。
|
|
12
|
+
*
|
|
13
|
+
* 展开交互交给 `<details>`:零 JS,刷新即最新,坏处只是不能记住展开状态 —— 面板不需要。
|
|
14
|
+
*/
|
|
15
|
+
import type { AuditEntry } from "./audit-log.ts";
|
|
16
|
+
import type { WebControls } from "./activity.ts";
|
|
17
|
+
import type { WebState, WebView } from "./data.ts";
|
|
18
|
+
import {
|
|
19
|
+
NO_FILTER,
|
|
20
|
+
buildViewPage,
|
|
21
|
+
isDefaultFilter,
|
|
22
|
+
pageHref,
|
|
23
|
+
parseView,
|
|
24
|
+
type ListFilter,
|
|
25
|
+
type ViewPage,
|
|
26
|
+
} from "./filter.ts";
|
|
27
|
+
import { appliedRows, approvalRows, doneRows, pendingRows, reportRows, type RowContext } from "./render-rows.ts";
|
|
28
|
+
import { renderAudit, renderControlBar, renderFilterForm, renderPagination } from "./render-controls.ts";
|
|
29
|
+
import { VIEW_LABEL, shellPage } from "./shell.ts";
|
|
30
|
+
import { escapeHtml } from "./text.ts";
|
|
31
|
+
|
|
32
|
+
// 这些格式化函数原来住在本文件,拆文件后仍然从这里导出:既有测试与调用方都按这个路径取
|
|
33
|
+
export { escapeHtml, formatStamp, shortRunId } from "./text.ts";
|
|
34
|
+
export { parseView } from "./filter.ts";
|
|
35
|
+
|
|
36
|
+
/** 每个列表页的空态文案:写清「为什么空」与「空在这里意味着什么」,不是一句「暂无数据」 */
|
|
37
|
+
export const EMPTY: Record<WebView, string> = {
|
|
38
|
+
pending: "暂无待处理:所有复盘都已完成,也没有待人批的改动。",
|
|
39
|
+
approvals: "没有待批:auto 级不需要人批,approval 级的会出现在这里。",
|
|
40
|
+
applied: "还没有已采纳的提案:采纳过的会连 diff 一起留在这里。",
|
|
41
|
+
done: "暂无已处理记录:还没有回滚、失败或被否决的提案。",
|
|
42
|
+
reports: "暂无复盘报告:还没有 run 写过 01-findings.json。",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** 页脚按视图分:只有待审批页能点,其它页要把「去哪批」写清楚 */
|
|
46
|
+
function footer(view: WebView, canWrite: boolean): string {
|
|
47
|
+
const api = `机器可读副本:<a href="/api/state">/api/state</a>。`;
|
|
48
|
+
if (view === "approvals") {
|
|
49
|
+
if (!canWrite) {
|
|
50
|
+
return `本实例只读:没注入审批回调,页面上不能点批准 —— 采纳请回 TUI 跑 <code>/skill-evolve approve <runId></code>。${api}`;
|
|
51
|
+
}
|
|
52
|
+
return `点「批准」= 在 TUI 敲 <code>/skill-evolve approve <runId></code>,走同一套五道闸;「拒绝」只改队列状态,不动任何技能文件。${api}`;
|
|
53
|
+
}
|
|
54
|
+
return `只读面板:只绑 127.0.0.1、无鉴权、不能在这里点批准 ——
|
|
55
|
+
采纳请回 TUI 跑 <code>/skill-evolve approve <runId></code>,或去<a href="/?view=approvals">待审批页</a>直接批。${api}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 列表区:筛选表单 + 列表 + 分页。待审批页不给筛选也不分页 ——
|
|
60
|
+
* 它必须在一屏里看全(每个都等着人点),筛掉一部分只会制造「怎么还有没批的」。
|
|
61
|
+
*/
|
|
62
|
+
function listSection(view: WebView, page: ViewPage, filter: ListFilter, ctx: RowContext): string {
|
|
63
|
+
const body =
|
|
64
|
+
page.view === "pending"
|
|
65
|
+
? pendingRows(page.rows, ctx)
|
|
66
|
+
: page.view === "approvals"
|
|
67
|
+
? approvalRows(page.rows, ctx)
|
|
68
|
+
: page.view === "applied"
|
|
69
|
+
? appliedRows(page.rows)
|
|
70
|
+
: page.view === "done"
|
|
71
|
+
? doneRows(page.rows)
|
|
72
|
+
: reportRows(page.rows, ctx);
|
|
73
|
+
const list = body.length === 0 ? `<p class="empty">${EMPTY[view]}</p>` : `<ul>\n${body}\n</ul>`;
|
|
74
|
+
if (view === "approvals") return `<main>\n${list}\n</main>`;
|
|
75
|
+
return `<main>
|
|
76
|
+
${renderFilterForm(view, filter)}
|
|
77
|
+
${list}
|
|
78
|
+
${renderPagination(view, page, filter)}
|
|
79
|
+
</main>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface RenderOptions {
|
|
83
|
+
/** 表单 CSRF token:只读实例不传,按钮就不渲染 */
|
|
84
|
+
csrf?: string;
|
|
85
|
+
/** 是否允许渲染写操作按钮:只有注入了回调的实例才 true */
|
|
86
|
+
canWrite?: boolean;
|
|
87
|
+
/** 上一步动作的结果 / 错误(来自 303 的 msg / err),页面顶部一条横幅 */
|
|
88
|
+
notice?: { kind: "msg" | "err"; text: string } | null;
|
|
89
|
+
/** 控制条数据(开关状态 + 最后活动):不传就不渲染控制条 */
|
|
90
|
+
controls?: WebControls | null;
|
|
91
|
+
/** 最近操作;不传就不渲染「最近操作」区(空数组则渲染成「还没有记录」) */
|
|
92
|
+
audit?: readonly AuditEntry[] | null;
|
|
93
|
+
/** 列表筛选;不传按默认(全部技能 / 全部状态 / 最近 7 天 / 第 1 页) */
|
|
94
|
+
filter?: ListFilter;
|
|
95
|
+
/** 现时(单测注入固定时钟) */
|
|
96
|
+
now?: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function renderPage(state: WebState, view: WebView, options: RenderOptions = {}): string {
|
|
100
|
+
const csrf = options.csrf ?? "";
|
|
101
|
+
const writable = options.canWrite === true && csrf.length > 0;
|
|
102
|
+
const now = options.now ?? Date.now();
|
|
103
|
+
// 渲染层不自带筛选:不传就是「全部、第 1 页」;产品默认的 7 天由 HTTP 层(parseListQuery)给
|
|
104
|
+
const filter = options.filter ?? NO_FILTER;
|
|
105
|
+
const page = buildViewPage(state, view, filter, now);
|
|
106
|
+
const ctx: RowContext = { csrf, writable, view };
|
|
107
|
+
// 只给「待处理」加 5 秒自刷新:等待时人会停留,看档案 / 历史时刷新只会把页面跳回去;
|
|
108
|
+
// 待审批页有按钮,刷新会打断正在点的动作,同样不加。
|
|
109
|
+
// 带筛选时刷新必须带上同样的筛选参数,否则挑完技能看着看着就被弹回全部 —— 没筛时保持最短 URL。
|
|
110
|
+
// 渲染层不传 filter(= 全量、第 1 页)与默认筛选(7d、第 1 页且没挑技能/状态)都用最短 URL
|
|
111
|
+
const plainRefresh = options.filter === undefined || isDefaultFilter(filter);
|
|
112
|
+
const refresh =
|
|
113
|
+
view === "pending"
|
|
114
|
+
? `<meta http-equiv="refresh" content="5; url=${plainRefresh ? "?view=pending" : escapeHtml(pageHref(view, filter, page.page))}">\n`
|
|
115
|
+
: "";
|
|
116
|
+
const notice = options.notice
|
|
117
|
+
? `<p class="notice${options.notice.kind === "err" ? " err" : ""}">${escapeHtml(options.notice.text)}</p>\n`
|
|
118
|
+
: "";
|
|
119
|
+
const top = [
|
|
120
|
+
options.controls ? renderControlBar(options.controls, { view, now, csrf, canWrite: writable }) : "",
|
|
121
|
+
notice.trimEnd(),
|
|
122
|
+
]
|
|
123
|
+
.filter((part) => part.length > 0)
|
|
124
|
+
.map((part) => `${part}\n`)
|
|
125
|
+
.join("");
|
|
126
|
+
const audit = options.audit ? `${renderAudit(options.audit)}\n` : "";
|
|
127
|
+
return shellPage({
|
|
128
|
+
state,
|
|
129
|
+
title: VIEW_LABEL[view],
|
|
130
|
+
active: view,
|
|
131
|
+
head: refresh,
|
|
132
|
+
top,
|
|
133
|
+
body: listSection(view, page, filter, ctx),
|
|
134
|
+
foot: `${audit}${footer(view, writable)}`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 复盘报告视图的数据:每个跑过 `01-findings.json` 的 run 一行,另加「卡死且没产出」的 run(标 stale)。
|
|
3
|
+
* 判据仍然是 findings 能读;例外是 status=stale 的失败 run —— 它们也要在档案里有一行。
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { readVerify } from "../apply.ts";
|
|
8
|
+
import type { RunSummary } from "../runs.ts";
|
|
9
|
+
import { readRunError } from "../stale.ts";
|
|
10
|
+
import { asNumber, asRecord, asText, readJson, reasonLabel } from "./json-read.ts";
|
|
11
|
+
|
|
12
|
+
/** 单条报告里最多列几个技能:全量列会把 details 撑成第二份 findings */
|
|
13
|
+
export const MAX_FINDING_ROWS = 5;
|
|
14
|
+
|
|
15
|
+
export interface FindingRow {
|
|
16
|
+
skill: string;
|
|
17
|
+
kind: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RunReport {
|
|
22
|
+
runId: string;
|
|
23
|
+
status: string;
|
|
24
|
+
trigger: string;
|
|
25
|
+
startedAt: number | null;
|
|
26
|
+
findings: { total: number; rows: FindingRow[] };
|
|
27
|
+
proposal: { type: string; skill: string; added: number; removed: number } | null;
|
|
28
|
+
review: { status: string; verdict: string | null; blocking: number } | null;
|
|
29
|
+
decision: { status: string; approver: string; at: number | null; detail: string } | null;
|
|
30
|
+
verify: { status: string; at: number | null; gates: { gate: string; name: string; ok: boolean }[] } | null;
|
|
31
|
+
/** 失败留档:卡死原因 + 日志末行(有它说明这份报告是「没跑完」而不是「没发现」) */
|
|
32
|
+
failure?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** findings 读不出来(缺文件 / 坏 JSON)返回 null:报告页不能拿「0 条」冒充「解析失败」 */
|
|
36
|
+
function readFindings(runDir: string): RunReport["findings"] | null {
|
|
37
|
+
const raw = readJson<Record<string, unknown>>(path.join(runDir, "01-findings.json"));
|
|
38
|
+
if (!raw) return null;
|
|
39
|
+
const skills = Array.isArray(raw.skills) ? raw.skills : [];
|
|
40
|
+
const rows: FindingRow[] = [];
|
|
41
|
+
for (const entry of skills) {
|
|
42
|
+
const skill = asRecord(entry);
|
|
43
|
+
if (!skill) continue;
|
|
44
|
+
const loaded = skill.loaded === true;
|
|
45
|
+
const code = asText(skill.whyNotLoaded);
|
|
46
|
+
rows.push({
|
|
47
|
+
skill: asText(skill.name, "(未命名)"),
|
|
48
|
+
kind: loaded ? "已加载" : skill.advertised === false ? "未曝光" : "未加载",
|
|
49
|
+
reason: loaded ? "—" : code.length > 0 ? reasonLabel(code) : "未写归因",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return { total: rows.length, rows: rows.slice(0, MAX_FINDING_ROWS) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readReview(runDir: string): RunReport["review"] {
|
|
56
|
+
const raw = readJson<Record<string, unknown>>(path.join(runDir, "03-review.json"));
|
|
57
|
+
if (!raw) return null;
|
|
58
|
+
return {
|
|
59
|
+
status: asText(raw.status, "unknown"),
|
|
60
|
+
verdict: typeof raw.verdict === "string" ? raw.verdict : null,
|
|
61
|
+
blocking: Array.isArray(raw.blocking) ? raw.blocking.length : 0,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readDecision(runDir: string): RunReport["decision"] {
|
|
66
|
+
const raw = readJson<Record<string, unknown>>(path.join(runDir, "04-decision.json"));
|
|
67
|
+
if (!raw) return null;
|
|
68
|
+
return {
|
|
69
|
+
status: asText(raw.status, "unknown"),
|
|
70
|
+
approver: asText(raw.approver, "?"),
|
|
71
|
+
at: asNumber(raw.at),
|
|
72
|
+
detail: asText(raw.detail),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readVerifyGates(runDir: string): RunReport["verify"] {
|
|
77
|
+
const record = readVerify(runDir);
|
|
78
|
+
if (!record) return null;
|
|
79
|
+
const gates = Array.isArray(record.gates) ? record.gates : [];
|
|
80
|
+
return {
|
|
81
|
+
status: asText(record.status, "unknown"),
|
|
82
|
+
at: asNumber(record.at),
|
|
83
|
+
gates: gates.map((gate) => ({ gate: asText(gate.gate), name: asText(gate.name), ok: gate.ok === true })),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 触发者只在 run.json 里(listRuns 的摘要不带它):单独读一次,坏记录当 unknown */
|
|
88
|
+
function readTrigger(runDir: string): string {
|
|
89
|
+
const raw = readJson<Record<string, unknown>>(path.join(runDir, "run.json"));
|
|
90
|
+
return asText(raw?.trigger, "unknown");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** runs/ 下的目录名(不看 run.json,坏掉的目录也要能被 reports 的真实判据「有没有 01-findings」筛) */
|
|
94
|
+
function runDirs(evolutionDir: string): { runId: string; runDir: string }[] {
|
|
95
|
+
const root = path.join(evolutionDir, "runs");
|
|
96
|
+
let names: string[];
|
|
97
|
+
try {
|
|
98
|
+
names = fs.readdirSync(root);
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const found: { runId: string; runDir: string }[] = [];
|
|
103
|
+
for (const name of names) {
|
|
104
|
+
if (name.startsWith(".")) continue;
|
|
105
|
+
const runDir = path.join(root, name);
|
|
106
|
+
try {
|
|
107
|
+
if (!fs.statSync(runDir).isDirectory()) continue;
|
|
108
|
+
} catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
found.push({ runId: name, runDir });
|
|
112
|
+
}
|
|
113
|
+
return found;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildReports(evolutionDir: string, runs: readonly RunSummary[]): RunReport[] {
|
|
117
|
+
const summaries = new Map(runs.map((run) => [run.runId, run]));
|
|
118
|
+
const reports: RunReport[] = [];
|
|
119
|
+
for (const { runId, runDir } of runDirs(evolutionDir)) {
|
|
120
|
+
const findings = readFindings(runDir);
|
|
121
|
+
if (!findings) {
|
|
122
|
+
// 失败也要有一行档案:每条 run 都能在报告里找到,不能因为没产出就从历史里消失
|
|
123
|
+
const summary = summaries.get(runId);
|
|
124
|
+
const raw = readJson<Record<string, unknown>>(path.join(runDir, "run.json"));
|
|
125
|
+
if ((summary?.status ?? asText(raw?.status)) !== "stale") continue;
|
|
126
|
+
const error = readRunError(runDir);
|
|
127
|
+
const tail = error?.childLogTail ?? [];
|
|
128
|
+
const lastLine = tail.length > 0 ? tail[tail.length - 1]!.trim() : "";
|
|
129
|
+
reports.push({
|
|
130
|
+
runId,
|
|
131
|
+
status: "stale",
|
|
132
|
+
trigger: readTrigger(runDir),
|
|
133
|
+
startedAt: summary?.dispatchedAt ?? asNumber(raw?.dispatchedAt),
|
|
134
|
+
findings: { total: 0, rows: [] },
|
|
135
|
+
proposal: null,
|
|
136
|
+
review: readReview(runDir),
|
|
137
|
+
decision: readDecision(runDir),
|
|
138
|
+
verify: readVerifyGates(runDir),
|
|
139
|
+
failure: [
|
|
140
|
+
`原因:${error?.reason === "timeout" ? "超时(子进程还活着但超过单 run 上限)" : "子进程已退出"}`,
|
|
141
|
+
`日志末行:${lastLine.length > 0 ? lastLine : "(子进程没留下任何输出)"}`,
|
|
142
|
+
].join("\n"),
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const proposal = summaries.get(runId)?.proposal;
|
|
147
|
+
reports.push({
|
|
148
|
+
runId,
|
|
149
|
+
status: summaries.get(runId)?.status ?? "unknown",
|
|
150
|
+
trigger: readTrigger(runDir),
|
|
151
|
+
startedAt: summaries.get(runId)?.dispatchedAt ?? null,
|
|
152
|
+
findings,
|
|
153
|
+
proposal: proposal ? { type: proposal.type, skill: proposal.skill, added: proposal.added, removed: proposal.removed } : null,
|
|
154
|
+
review: readReview(runDir),
|
|
155
|
+
decision: readDecision(runDir),
|
|
156
|
+
verify: readVerifyGates(runDir),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return reports.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
|
|
160
|
+
}
|