pi-skill-evolve 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +81 -2
  3. package/extensions/skill-evolve/fixtures/selftest/delete-ban/after.md +10 -0
  4. package/extensions/skill-evolve/fixtures/selftest/delete-ban/change.diff +8 -0
  5. package/extensions/skill-evolve/fixtures/selftest/delete-ban/fixture.json +6 -0
  6. package/extensions/skill-evolve/fixtures/selftest/rg-to-find/after.md +9 -0
  7. package/extensions/skill-evolve/fixtures/selftest/rg-to-find/change.diff +8 -0
  8. package/extensions/skill-evolve/fixtures/selftest/rg-to-find/fixture.json +6 -0
  9. package/extensions/skill-evolve/fixtures/selftest/weak-description/after.md +8 -0
  10. package/extensions/skill-evolve/fixtures/selftest/weak-description/change.diff +8 -0
  11. package/extensions/skill-evolve/fixtures/selftest/weak-description/fixture.json +6 -0
  12. package/extensions/skill-evolve/index.ts +300 -0
  13. package/extensions/skill-evolve/lib/apply-gates.ts +204 -0
  14. package/extensions/skill-evolve/lib/apply-targets.ts +90 -0
  15. package/extensions/skill-evolve/lib/apply.ts +141 -0
  16. package/extensions/skill-evolve/lib/attribution.ts +138 -0
  17. package/extensions/skill-evolve/lib/audit.ts +221 -0
  18. package/extensions/skill-evolve/lib/baseline.ts +176 -0
  19. package/extensions/skill-evolve/lib/child-env.ts +10 -0
  20. package/extensions/skill-evolve/lib/classify-action.ts +161 -0
  21. package/extensions/skill-evolve/lib/command.ts +100 -0
  22. package/extensions/skill-evolve/lib/config-command.ts +38 -0
  23. package/extensions/skill-evolve/lib/config.ts +194 -0
  24. package/extensions/skill-evolve/lib/detect.ts +62 -0
  25. package/extensions/skill-evolve/lib/diff-apply.ts +90 -0
  26. package/extensions/skill-evolve/lib/diff-gen.ts +78 -0
  27. package/extensions/skill-evolve/lib/dispatch.ts +300 -0
  28. package/extensions/skill-evolve/lib/evolution-apply.ts +265 -0
  29. package/extensions/skill-evolve/lib/evolution-model.ts +72 -0
  30. package/extensions/skill-evolve/lib/evolution-observe.ts +82 -0
  31. package/extensions/skill-evolve/lib/evolution-views.ts +89 -0
  32. package/extensions/skill-evolve/lib/evolution.ts +289 -0
  33. package/extensions/skill-evolve/lib/flags.ts +44 -0
  34. package/extensions/skill-evolve/lib/gate0.ts +78 -0
  35. package/extensions/skill-evolve/lib/hard-rules.ts +167 -0
  36. package/extensions/skill-evolve/lib/lock.ts +215 -0
  37. package/extensions/skill-evolve/lib/logfile.ts +52 -0
  38. package/extensions/skill-evolve/lib/mechanical-report.ts +95 -0
  39. package/extensions/skill-evolve/lib/mechanical.ts +136 -0
  40. package/extensions/skill-evolve/lib/metrics.ts +172 -0
  41. package/extensions/skill-evolve/lib/model-config.ts +95 -0
  42. package/extensions/skill-evolve/lib/notify.ts +132 -0
  43. package/extensions/skill-evolve/lib/panel.ts +174 -0
  44. package/extensions/skill-evolve/lib/pending.ts +34 -0
  45. package/extensions/skill-evolve/lib/proc.ts +65 -0
  46. package/extensions/skill-evolve/lib/prompt.ts +80 -0
  47. package/extensions/skill-evolve/lib/proposal.ts +227 -0
  48. package/extensions/skill-evolve/lib/prune-plan.ts +62 -0
  49. package/extensions/skill-evolve/lib/queue.ts +136 -0
  50. package/extensions/skill-evolve/lib/replay-fixture.ts +80 -0
  51. package/extensions/skill-evolve/lib/replay.ts +246 -0
  52. package/extensions/skill-evolve/lib/report.ts +181 -0
  53. package/extensions/skill-evolve/lib/review-post.ts +101 -0
  54. package/extensions/skill-evolve/lib/review-prompt.ts +82 -0
  55. package/extensions/skill-evolve/lib/review-record.ts +63 -0
  56. package/extensions/skill-evolve/lib/review-run.ts +131 -0
  57. package/extensions/skill-evolve/lib/review-spawn.ts +83 -0
  58. package/extensions/skill-evolve/lib/review-supervisor.ts +267 -0
  59. package/extensions/skill-evolve/lib/round.ts +116 -0
  60. package/extensions/skill-evolve/lib/run-watch.ts +34 -0
  61. package/extensions/skill-evolve/lib/runs.ts +208 -0
  62. package/extensions/skill-evolve/lib/selftest.ts +201 -0
  63. package/extensions/skill-evolve/lib/session-index.ts +145 -0
  64. package/extensions/skill-evolve/lib/shutdown-dispatch.ts +177 -0
  65. package/extensions/skill-evolve/lib/single-flight.ts +111 -0
  66. package/extensions/skill-evolve/lib/spawn-plan.ts +103 -0
  67. package/extensions/skill-evolve/lib/stale.ts +217 -0
  68. package/extensions/skill-evolve/lib/telemetry.ts +205 -0
  69. package/extensions/skill-evolve/lib/ui-gate.ts +34 -0
  70. package/extensions/skill-evolve/lib/web/actions.ts +299 -0
  71. package/extensions/skill-evolve/lib/web/activity.ts +57 -0
  72. package/extensions/skill-evolve/lib/web/applied.ts +64 -0
  73. package/extensions/skill-evolve/lib/web/approvals.ts +58 -0
  74. package/extensions/skill-evolve/lib/web/audit-log.ts +115 -0
  75. package/extensions/skill-evolve/lib/web/autostart.ts +177 -0
  76. package/extensions/skill-evolve/lib/web/data.ts +298 -0
  77. package/extensions/skill-evolve/lib/web/diff-lines.ts +141 -0
  78. package/extensions/skill-evolve/lib/web/filter.ts +203 -0
  79. package/extensions/skill-evolve/lib/web/http.ts +68 -0
  80. package/extensions/skill-evolve/lib/web/json-read.ts +33 -0
  81. package/extensions/skill-evolve/lib/web/panel-pid.ts +99 -0
  82. package/extensions/skill-evolve/lib/web/render-controls.ts +156 -0
  83. package/extensions/skill-evolve/lib/web/render-rows.ts +252 -0
  84. package/extensions/skill-evolve/lib/web/render-run.ts +188 -0
  85. package/extensions/skill-evolve/lib/web/render-skills.ts +164 -0
  86. package/extensions/skill-evolve/lib/web/render.ts +136 -0
  87. package/extensions/skill-evolve/lib/web/reports.ts +160 -0
  88. package/extensions/skill-evolve/lib/web/run-detail.ts +234 -0
  89. package/extensions/skill-evolve/lib/web/run-path.ts +21 -0
  90. package/extensions/skill-evolve/lib/web/server.ts +268 -0
  91. package/extensions/skill-evolve/lib/web/shell.ts +87 -0
  92. package/extensions/skill-evolve/lib/web/skills.ts +202 -0
  93. package/extensions/skill-evolve/lib/web/style.ts +107 -0
  94. package/extensions/skill-evolve/lib/web/text.ts +52 -0
  95. package/extensions/skill-evolve/lib/web/trash.ts +36 -0
  96. package/extensions/skill-evolve/lib/weekly.ts +285 -0
  97. package/extensions/skill-evolve/prompts//345/244/215/347/233/230.md +172 -0
  98. package/extensions/skill-evolve/scripts/replay.ts +93 -0
  99. package/extensions/skill-evolve/test/apply.test.ts +278 -0
  100. package/extensions/skill-evolve/test/approve.test.ts +248 -0
  101. package/extensions/skill-evolve/test/attribution.test.ts +133 -0
  102. package/extensions/skill-evolve/test/audit.test.ts +151 -0
  103. package/extensions/skill-evolve/test/baseline.test.ts +132 -0
  104. package/extensions/skill-evolve/test/classify-action.test.ts +133 -0
  105. package/extensions/skill-evolve/test/command.test.ts +267 -0
  106. package/extensions/skill-evolve/test/config-command.test.ts +73 -0
  107. package/extensions/skill-evolve/test/config.test.ts +154 -0
  108. package/extensions/skill-evolve/test/detect.test.ts +71 -0
  109. package/extensions/skill-evolve/test/diff-apply.test.ts +46 -0
  110. package/extensions/skill-evolve/test/dispatch-busy.test.ts +223 -0
  111. package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +88 -0
  112. package/extensions/skill-evolve/test/dispatch.test.ts +306 -0
  113. package/extensions/skill-evolve/test/evolution.test.ts +378 -0
  114. package/extensions/skill-evolve/test/fake-review-spawn.ts +60 -0
  115. package/extensions/skill-evolve/test/hard-rules.test.ts +123 -0
  116. package/extensions/skill-evolve/test/lock.test.ts +138 -0
  117. package/extensions/skill-evolve/test/logfile.test.ts +85 -0
  118. package/extensions/skill-evolve/test/mechanical-wiring.test.ts +217 -0
  119. package/extensions/skill-evolve/test/metrics.test.ts +129 -0
  120. package/extensions/skill-evolve/test/model-config.test.ts +84 -0
  121. package/extensions/skill-evolve/test/notify.test.ts +133 -0
  122. package/extensions/skill-evolve/test/observe.test.ts +100 -0
  123. package/extensions/skill-evolve/test/panel-pid.test.ts +84 -0
  124. package/extensions/skill-evolve/test/panel.test.ts +115 -0
  125. package/extensions/skill-evolve/test/proposal.test.ts +159 -0
  126. package/extensions/skill-evolve/test/queue.test.ts +110 -0
  127. package/extensions/skill-evolve/test/reject.test.ts +134 -0
  128. package/extensions/skill-evolve/test/replay.test.ts +111 -0
  129. package/extensions/skill-evolve/test/report.test.ts +66 -0
  130. package/extensions/skill-evolve/test/review-post.test.ts +92 -0
  131. package/extensions/skill-evolve/test/review-prompt.test.ts +105 -0
  132. package/extensions/skill-evolve/test/review-spawn.test.ts +81 -0
  133. package/extensions/skill-evolve/test/review-supervisor.test.ts +243 -0
  134. package/extensions/skill-evolve/test/selftest.test.ts +113 -0
  135. package/extensions/skill-evolve/test/session-index.test.ts +128 -0
  136. package/extensions/skill-evolve/test/single-flight.test.ts +122 -0
  137. package/extensions/skill-evolve/test/stale.test.ts +272 -0
  138. package/extensions/skill-evolve/test/telemetry.test.ts +145 -0
  139. package/extensions/skill-evolve/test/ui-gate.test.ts +77 -0
  140. package/extensions/skill-evolve/test/web-actions.test.ts +262 -0
  141. package/extensions/skill-evolve/test/web-applied.test.ts +100 -0
  142. package/extensions/skill-evolve/test/web-audit.test.ts +87 -0
  143. package/extensions/skill-evolve/test/web-autostart.test.ts +268 -0
  144. package/extensions/skill-evolve/test/web-data.test.ts +420 -0
  145. package/extensions/skill-evolve/test/web-diff-lines.test.ts +155 -0
  146. package/extensions/skill-evolve/test/web-filter.test.ts +212 -0
  147. package/extensions/skill-evolve/test/web-panel-pid.test.ts +97 -0
  148. package/extensions/skill-evolve/test/web-render-run.test.ts +132 -0
  149. package/extensions/skill-evolve/test/web-render-skills.test.ts +134 -0
  150. package/extensions/skill-evolve/test/web-render.test.ts +300 -0
  151. package/extensions/skill-evolve/test/web-run-detail.test.ts +256 -0
  152. package/extensions/skill-evolve/test/web-server-views.test.ts +113 -0
  153. package/extensions/skill-evolve/test/web-server.test.ts +465 -0
  154. package/extensions/skill-evolve/test/web-shell.test.ts +67 -0
  155. package/extensions/skill-evolve/test/web-skills.test.ts +239 -0
  156. package/extensions/skill-evolve/test/weekly.test.ts +123 -0
  157. package/package.json +34 -4
@@ -0,0 +1,156 @@
1
+ /**
2
+ * 页面骨架里的四块「管理性质」的东西:顶部控制条、列表筛选、分页、最近操作。
3
+ *
4
+ * 都不依赖列表行,所以与 render-rows.ts 分开。
5
+ *
6
+ * 两条设计取舍:
7
+ * 1. **控制条只在服务端传了数据时才渲染**:渲染层不自己去读盘(否则 renderPage 会变成「读盘 + 渲染」两件事),
8
+ * 也不在拿不到开关状态时猜一个出来 —— 顶部写「复盘:开」而实际是关的,比不写更糟;
9
+ * 2. **筛选用 `<form method="get">` + hidden view**:零 JS,所以筛选结果能被拷成链接、
10
+ * 能退回上一页、能在刷新后保持;翻页链接由 pageHref 带上全部筛选参数,不保留就等于点下一页即清空筛选。
11
+ */
12
+ import type { AuditEntry } from "./audit-log.ts";
13
+ import type { ConfigFlag } from "../config.ts";
14
+ import { FLAG_EFFECT, FLAG_LABEL, TELEMETRY_OFF_WARNING, telemetryOffWhileEnabled } from "../flags.ts";
15
+ import type { WebView } from "./data.ts";
16
+ import { RANGE_KEYS, RANGE_LABEL, STATUS_OPTIONS, pageHref, type ListFilter, type ViewPage } from "./filter.ts";
17
+ import { escapeHtml, formatStamp } from "./text.ts";
18
+ import type { WebControls } from "./activity.ts";
19
+
20
+ export interface ControlBarOptions {
21
+ /** 动作用户在哪一页点的,就回哪一页:技能管理页也要能开关采集(曝光数据就是它采的) */
22
+ view: WebView | "skills";
23
+ /** 现时(单测注入固定时钟):决定「最后活动」写成几分钟前 */
24
+ now: number;
25
+ csrf?: string;
26
+ canWrite?: boolean;
27
+ }
28
+
29
+ const MINUTE_MS = 60_000;
30
+
31
+ /**
32
+ * 「最后活动」的人话:只给粗粒度,不给秒 —— 精确到秒反而看不出差别,
33
+ * 而「几分钟前 / 几小时前 / 几天前」正好是「这个插件还在动吗」需要的精度。
34
+ */
35
+ export function activityText(at: number | null, now: number): string {
36
+ if (at === null) return "最后活动:还没有任何记录";
37
+ const minutes = Math.floor(Math.max(0, now - at) / MINUTE_MS);
38
+ if (minutes < 1) return "最后活动:刚刚";
39
+ if (minutes < 60) return `最后活动:${minutes} 分钟前`;
40
+ const hours = Math.floor(minutes / 60);
41
+ if (hours < 24) return `最后活动:${hours} 小时前`;
42
+ return `最后活动:${Math.floor(hours / 24)} 天前`;
43
+ }
44
+
45
+ /** 控制条上的「立即复盘」按钮;只读面板不渲染任何表单 */
46
+ function runForm(ctx: ControlBarOptions): string {
47
+ return `<form method="post" action="/action">
48
+ <input type="hidden" name="csrf" value="${escapeHtml(ctx.csrf ?? "")}">
49
+ <input type="hidden" name="name" value="run">
50
+ <input type="hidden" name="view" value="${ctx.view}">
51
+ <button type="submit">立即复盘</button>
52
+ </form>`;
53
+ }
54
+
55
+ /**
56
+ * 一个独立开关:状态 + 后果说明 + 「点下去会发生什么」的按钮。
57
+ *
58
+ * 两个开关各自 post 到 `/toggle?field=`(带上隐藏的 view,动作完回同一页),
59
+ * 按钮文案写动作不写状态 —— 状态已经在左边那一段里,按钮再写一遍会让人把「当前值」看成「点了会变的值」。
60
+ */
61
+ function flagSwitch(field: ConfigFlag, on: boolean, ctx: ControlBarOptions): string {
62
+ const state = `<span class="state ${on ? "on" : "off"}">${FLAG_LABEL[field]}:${on ? "开" : "关"}</span>`;
63
+ const effect = `<span class="hint">${FLAG_EFFECT[field]}</span>`;
64
+ const writable = ctx.canWrite === true && (ctx.csrf ?? "").length > 0;
65
+ const form = writable
66
+ ? `<form method="post" action="/toggle?field=${field}">
67
+ <input type="hidden" name="csrf" value="${escapeHtml(ctx.csrf ?? "")}">
68
+ <input type="hidden" name="view" value="${ctx.view}">
69
+ <button type="submit">${on ? "关闭" : "开启"}${FLAG_LABEL[field]}</button>
70
+ </form>`
71
+ : "";
72
+ return `<span class="sw">${state}${effect}${form}</span>`;
73
+ }
74
+
75
+ /**
76
+ * 顶部控制条:两个独立开关(采集 / 复盘)+ 立即复盘 + 最后活动。
77
+ *
78
+ * 两个开关不许合成一个「开启采集与派生」:采集只写本地文件,复盘才花 token,
79
+ * 想采集样本但不想花钱是常见的中间状态,捆在一起就无路可走。
80
+ * 只开复盘不采集时另给一行警告(见 flags.ts 的 TELEMETRY_OFF_WARNING),不许静默。
81
+ */
82
+ export function renderControlBar(controls: WebControls, ctx: ControlBarOptions): string {
83
+ const writable = ctx.canWrite === true && (ctx.csrf ?? "").length > 0;
84
+ const run = writable ? runForm(ctx) : "";
85
+ const warn = telemetryOffWhileEnabled(controls.enabled, controls.telemetry)
86
+ ? `<p class="warn">⚠ ${TELEMETRY_OFF_WARNING}</p>\n`
87
+ : "";
88
+ return `<div class="ctl">
89
+ ${flagSwitch("telemetry", controls.telemetry, ctx)}
90
+ ${flagSwitch("enabled", controls.enabled, ctx)}
91
+ ${run}<span class="act">${escapeHtml(activityText(controls.lastActivityAt, ctx.now))}</span>
92
+ </div>
93
+ ${warn}`;
94
+ }
95
+
96
+ /** 状态下拉:先「全部」,再固定七个状态(顺序与 STATUS_OPTIONS 一致,别在渲染层重排) */
97
+ function statusSelect(current: string): string {
98
+ const options = [`<option value="">全部状态</option>`];
99
+ for (const status of STATUS_OPTIONS) {
100
+ options.push(`<option value="${status}"${status === current ? " selected" : ""}>${status}</option>`);
101
+ }
102
+ return options.join("");
103
+ }
104
+
105
+ function rangeSelect(current: string): string {
106
+ return RANGE_KEYS.map(
107
+ (key) => `<option value="${key}"${key === current ? " selected" : ""}>${RANGE_LABEL[key]}</option>`,
108
+ ).join("");
109
+ }
110
+
111
+ /** 筛选表单:hidden view 保证 GET 提交后还在同一页;「重置」链回不带筛选的本页 */
112
+ export function renderFilterForm(view: WebView, filter: ListFilter): string {
113
+ return `<form class="filter" method="get">
114
+ <input type="hidden" name="view" value="${view}">
115
+ <label>技能 <input type="text" name="skill" value="${escapeHtml(filter.skill)}" placeholder="全部技能" size="16"></label>
116
+ <label>状态 <select name="status">${statusSelect(filter.status)}</select></label>
117
+ <label>时间 <select name="range">${rangeSelect(filter.range)}</select></label>
118
+ <button type="submit">筛选</button>
119
+ <a class="btn" href="/?view=${view}">重置</a>
120
+ </form>`;
121
+ }
122
+
123
+ /**
124
+ * 分页条:`第 X / Y 页,共 N 条` 永远显示(空列表也显示「第 1 / 1 页,共 0 条」——
125
+ * 页脚消失了人只会以为筛选坏了),只有到底时才把箭头换成灰字。
126
+ */
127
+ export function renderPagination(view: WebView, page: ViewPage, filter: ListFilter): string {
128
+ const prev =
129
+ page.page > 1
130
+ ? `<a href="${escapeHtml(pageHref(view, filter, page.page - 1))}">← 上一页</a>`
131
+ : `<span class="off">← 上一页</span>`;
132
+ const next =
133
+ page.page < page.pages
134
+ ? `<a href="${escapeHtml(pageHref(view, filter, page.page + 1))}">下一页 →</a>`
135
+ : `<span class="off">下一页 →</span>`;
136
+ return `<p class="page">${prev} <span class="count">第 ${page.page} / ${page.pages} 页,共 ${page.total} 条</span> ${next}</p>`;
137
+ }
138
+
139
+ /** 「最近操作」:时间 + 动作 + runId + 结果;结果里的 error 前缀单独染色,扫一眼就知道有没有出事 */
140
+ function auditRow(entry: AuditEntry): string {
141
+ const failed = entry.result.startsWith("error");
142
+ const cls = failed ? "fail" : entry.result === "partial" ? "partial" : "ok";
143
+ const runId = entry.runId ? ` · <code>${escapeHtml(entry.runId)}</code>` : "";
144
+ return `<li><span class="when">${formatStamp(entry.at)}</span> · <b>${entry.action}</b>${runId} · <span class="${cls}">${escapeHtml(entry.result)}</span></li>`;
145
+ }
146
+
147
+ export function renderAudit(entries: readonly AuditEntry[]): string {
148
+ const body =
149
+ entries.length === 0
150
+ ? `<p class="empty">还没有网页操作记录。</p>`
151
+ : `<ul>\n${entries.map(auditRow).join("\n")}\n</ul>`;
152
+ return `<section class="audit">
153
+ <h2>最近操作</h2>
154
+ ${body}
155
+ </section>`;
156
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * 列表行的渲染:五种视图(待处理 / 待审批 / 已采纳 / 已处理 / 复盘报告)各一段,加上每行针对那条 run 的操作按钮。
3
+ *
4
+ * 为什么单独一个文件:行渲染是面板里最长的一段(抬头 + 详情 + 行内表单),
5
+ * 和页面骨架(状态栏 / 导航 / 页脚)混在一起必然破 300 行。
6
+ *
7
+ * 行的操作只有三个(重跑 / 清理 / 看日志),且**只在待处理与复盘报告页**出现:
8
+ * 待审批页逐行就是批准/拒绝,已采纳与已处理页是档案(只给看 diff / 看日志),都不挂「重跑」——
9
+ * 一个已采纳的 run 旁边放「重跑」按钮,只会让人以为它能重放整个采纳流程。
10
+ *
11
+ * 按钮只在 `writable` 且拿到 csrf 时渲染,绝不自己造一个「能点但写不动」的按钮;
12
+ * 「看日志」是只读 GET,只读面板也照常给(它不写任何东西)。
13
+ */
14
+ import type { AppliedItem, ApprovalItem, DoneItem, PendingItem, RunReport, WebView } from "./data.ts";
15
+ import { escapeHtml, formatStamp, shortRunId, withId } from "./text.ts";
16
+
17
+ /** 行内操作需要的上下文:csrf 与 canWrite 决定按钮渲不渲染,view 决定动作做完回哪一页 */
18
+ export interface RowContext {
19
+ csrf: string;
20
+ writable: boolean;
21
+ view: WebView;
22
+ }
23
+
24
+ /**
25
+ * 抬头里的 runId 做成链接(点进去看 run 详情页:diff / 评审 / 五闸)。
26
+ *
27
+ * 实现上**先整段转义、再把 shortRunId 那一段换成锚点**:抬头文本来自磁盘,
28
+ * 绝不能先拼 HTML 再转义(会出现两次转义与注入两种毛病)。
29
+ */
30
+ function linkRunId(text: string, runId: string): string {
31
+ const short = escapeHtml(shortRunId(runId));
32
+ const index = text.indexOf(short);
33
+ if (index < 0) return text;
34
+ const href = escapeHtml(`/?view=run&runId=${encodeURIComponent(runId)}`);
35
+ const anchor = `<a class="run" href="${href}">${short}</a>`;
36
+ return `${text.slice(0, index)}${anchor}${text.slice(index + short.length)}`;
37
+ }
38
+
39
+ /** 一行;detail 为空就不给 `<details>`(空折叠框比没有更烦);给了 runId 就把抬头里的 runId 变链接 */
40
+ function row(head: string, detail: string, acts = "", runId?: string): string {
41
+ const text = escapeHtml(head);
42
+ const rendered = runId === undefined ? text : linkRunId(text, runId);
43
+ const inner =
44
+ detail.length === 0
45
+ ? `<span class="head">${rendered}</span>`
46
+ : `<details><summary class="head">${rendered}</summary><pre>${escapeHtml(detail)}</pre></details>`;
47
+ return acts.length === 0 ? `<li class="row">${inner}</li>` : `<li class="row">${inner}\n${acts}</li>`;
48
+ }
49
+
50
+ /** 一个 `/action` 表单:csrf / name / runId / view 全走 hidden,按钮就是提交键 */
51
+ function runForm(name: "rerun" | "cleanup", runId: string, ctx: RowContext): string {
52
+ const label = name === "rerun" ? "重跑" : "清理记录";
53
+ const cls = name === "rerun" ? "" : ' class="danger"';
54
+ const title = name === "rerun" ? "" : ' title="移到 runs/.trash/,不会删数据"';
55
+ return `<form method="post" action="/action">
56
+ <input type="hidden" name="csrf" value="${escapeHtml(ctx.csrf)}">
57
+ <input type="hidden" name="name" value="${name}">
58
+ <input type="hidden" name="runId" value="${escapeHtml(runId)}">
59
+ <input type="hidden" name="view" value="${ctx.view}">
60
+ <button type="submit"${cls}${title}>${label}</button>
61
+ </form>`;
62
+ }
63
+
64
+ /**
65
+ * 行内操作条。`stale` 才给「清理记录」:清理是把目录移走,非卡死的 run 还在写盘,
66
+ * 移走等于把活进程的 cwd 抽掉(Linux 上不报错,但之后的写入全进孤儿 inode)。
67
+ */
68
+ function rowActs(runId: string, ctx: RowContext, stale: boolean): string {
69
+ const parts: string[] = [];
70
+ if (ctx.writable) {
71
+ parts.push(runForm("rerun", runId, ctx));
72
+ if (stale) parts.push(runForm("cleanup", runId, ctx));
73
+ }
74
+ parts.push(`<a class="btn" href="/log?runId=${encodeURIComponent(runId)}">看日志</a>`);
75
+ return `<div class="acts">\n${parts.join("\n")}\n</div>`;
76
+ }
77
+
78
+ /** 档案类行的操作:只有两个只读链接(看 diff / 看日志),不挂重跑 */
79
+ function archiveActs(runId: string): string {
80
+ const id = encodeURIComponent(runId);
81
+ return `<div class="acts">\n<a class="btn" href="${escapeHtml(`/?view=run&runId=${id}`)}">看 diff</a>\n<a class="btn" href="${escapeHtml(`/log?runId=${id}`)}">看日志</a>\n</div>`;
82
+ }
83
+
84
+ export function pendingRows(items: readonly PendingItem[], ctx: RowContext): string {
85
+ const badge: Record<PendingItem["kind"], string> = { running: "进行中", approval: "待批", stale: "卡死", reviewed: "待入队" };
86
+ return items
87
+ .map((item) => {
88
+ const skill = item.skill.length > 0 ? ` · ${item.skill}` : "";
89
+ return row(
90
+ `[${badge[item.kind]}] ${shortRunId(item.runId)}${skill} · ${formatStamp(item.at)}`,
91
+ withId(item.runId, item.detail),
92
+ rowActs(item.runId, ctx, item.kind === "stale"),
93
+ );
94
+ })
95
+ .join("\n");
96
+ }
97
+
98
+ export function doneRows(items: readonly DoneItem[]): string {
99
+ return items
100
+ .map((item) => {
101
+ const skill = item.skill.length > 0 ? ` · ${item.skill}` : "";
102
+ const level = item.level === "-" ? "" : ` · ${item.level}`;
103
+ return row(
104
+ `[${item.status}] ${shortRunId(item.runId)}${skill}${level} · ${formatStamp(item.at)}`,
105
+ withId(item.runId, item.detail),
106
+ archiveActs(item.runId),
107
+ item.runId,
108
+ );
109
+ })
110
+ .join("\n");
111
+ }
112
+
113
+ /** 五闸一句话:没记录时写「无记录」而不是「失败」(判不了 ≠ 没过) */
114
+ function gateText(item: AppliedItem): string {
115
+ if (item.gatesOk === null) return "五闸:无记录";
116
+ const failed = item.gates.filter((gate) => !gate.ok).length;
117
+ return failed === 0 ? "五闸:全 ok" : `五闸:${failed} 项失败`;
118
+ }
119
+
120
+ function appliedDetail(item: AppliedItem): string {
121
+ const gates =
122
+ item.gates.length === 0
123
+ ? ["五闸:没有 06-verify.json(无法判定)"]
124
+ : item.gates.map((gate) => ` ${gate.gate} ${gate.name} ${gate.ok ? "✓" : "✗"}`);
125
+ return [
126
+ `层级:${item.level}`,
127
+ `采纳:${formatStamp(item.appliedAt)}`,
128
+ `提案:${item.type} +${item.added}/-${item.removed} 行`,
129
+ ...gates,
130
+ item.gatesOk === false ? "注意:有闸没过却仍被采纳,先看 06-verify.json" : "",
131
+ "完整 diff:点上方 runId 进详情页",
132
+ ]
133
+ .filter((line) => line.length > 0)
134
+ .join("\n");
135
+ }
136
+
137
+ /** 已采纳:技能 · 提案类型 · +N/-M · 采纳时间 · 五闸结论(用户要的就是这五个字段) */
138
+ export function appliedRows(items: readonly AppliedItem[]): string {
139
+ return items
140
+ .map((item) => {
141
+ const skill = item.skill.length > 0 ? ` · ${item.skill}` : "";
142
+ const delta = ` · ${item.type} +${item.added}/-${item.removed}`;
143
+ return row(
144
+ `[applied] ${shortRunId(item.runId)}${skill}${delta} · ${formatStamp(item.appliedAt)} · ${gateText(item)}`,
145
+ appliedDetail(item),
146
+ archiveActs(item.runId),
147
+ item.runId,
148
+ );
149
+ })
150
+ .join("\n");
151
+ }
152
+
153
+ /** 待批条目的抬头:一眼看到「谁、改多少、评审怎么判」,详情留给 targets 与完整 runId */
154
+ function approvalHead(item: ApprovalItem): string {
155
+ const proposal = item.proposal ? ` · ${item.proposal.type} +${item.proposal.added}/-${item.proposal.removed}` : " · 无提案";
156
+ const verdict = item.review ? ` · ${item.review.verdict ?? "无 verdict"}/blocking ${item.review.blocking}` : "";
157
+ return `[${item.level}] ${shortRunId(item.runId)} · ${item.skill}${proposal}${verdict} · ${formatStamp(item.createdAt)}`;
158
+ }
159
+
160
+ function approvalDetail(item: ApprovalItem): string {
161
+ return [
162
+ `层级:${item.level}(必须人批;auto 级不在这里)`,
163
+ `目标:${item.targets.join("、") || "(未写 targets)"}`,
164
+ `入队:${formatStamp(item.createdAt)}`,
165
+ item.proposal
166
+ ? `提案:${item.proposal.type} +${item.proposal.added}/-${item.proposal.removed} 行`
167
+ : "提案:读不到 02-proposal.json",
168
+ item.review ? `评审:verdict=${item.review.verdict ?? "(未写)"} · blocking ${item.review.blocking} 条` : "评审:读不到 03-review.json",
169
+ item.detail ? `备注:${item.detail}` : "",
170
+ ]
171
+ .filter((line) => line.length > 0)
172
+ .join("\n");
173
+ }
174
+
175
+ /** 一个审批动作表单:csrf 与 runId 都走 hidden,按钮就是提交键 */
176
+ function actionForm(action: "approve" | "reject", item: ApprovalItem, csrf: string): string {
177
+ const label = action === "approve" ? "批准" : "拒绝";
178
+ const cls = action === "approve" ? "" : ' class="reject"';
179
+ return `<form method="post" action="/${action}">
180
+ <input type="hidden" name="csrf" value="${escapeHtml(csrf)}">
181
+ <input type="hidden" name="runId" value="${escapeHtml(item.runId)}">
182
+ <button type="submit"${cls}>${label}</button>
183
+ </form>`;
184
+ }
185
+
186
+ export function approvalRows(items: readonly ApprovalItem[], ctx: RowContext): string {
187
+ return items
188
+ .map((item) => {
189
+ const detail = `<details><summary class="head">${escapeHtml(approvalHead(item))}</summary><pre>${escapeHtml(
190
+ withId(item.runId, approvalDetail(item)),
191
+ )}</pre></details>`;
192
+ if (!ctx.writable) return `<li class="row">${detail}</li>`;
193
+ const acts = `<div class="acts">\n${actionForm("approve", item, ctx.csrf)}\n${actionForm("reject", item, ctx.csrf)}\n</div>`;
194
+ return `<li class="row">${detail}\n${acts}</li>`;
195
+ })
196
+ .join("\n");
197
+ }
198
+
199
+ function gateLine(report: RunReport): string {
200
+ if (!report.verify) return "验证:还没跑过五闸(未采纳)";
201
+ const marks = report.verify.gates.map((gate) => `${gate.gate}${gate.ok ? "✓" : "✗"}`).join(" ");
202
+ return `验证:${report.verify.status} · ${formatStamp(report.verify.at)} · ${marks || "(未记录闸门)"}`;
203
+ }
204
+
205
+ /** 报告的展开内容:按「结论 → 依据 → 执行」的顺序排,最后才是数字,方便先看结论 */
206
+ function reportDetail(report: RunReport): string {
207
+ const lines = [`状态:${report.status} · 触发:${report.trigger} · 开始:${formatStamp(report.startedAt)}`];
208
+ // 失败留档先给原因与日志末行:先知道“为什么没有 findings”,再看其它字段
209
+ if (report.failure) lines.push(report.failure);
210
+ const rows = report.findings.rows.map((item) => ` · ${item.skill} · ${item.kind} · ${item.reason}`);
211
+ lines.push(`findings:${report.findings.total} 条`);
212
+ lines.push(...rows);
213
+ if (report.findings.total > report.findings.rows.length) {
214
+ lines.push(` …另有 ${report.findings.total - report.findings.rows.length} 条(见 01-findings.json)`);
215
+ }
216
+ lines.push(
217
+ report.proposal
218
+ ? `提案:${report.proposal.type} ${report.proposal.skill} +${report.proposal.added}/-${report.proposal.removed}`
219
+ : "提案:无",
220
+ );
221
+ lines.push(
222
+ report.review
223
+ ? `评审:${report.review.status}${report.review.verdict ? ` verdict=${report.review.verdict}` : ""} · blocking ${report.review.blocking} 条`
224
+ : "评审:无",
225
+ );
226
+ lines.push(
227
+ report.decision
228
+ ? `决策:${report.decision.status} · ${report.decision.approver} · ${formatStamp(report.decision.at)}${report.decision.detail ? ` · ${report.decision.detail}` : ""}`
229
+ : "决策:未采纳(没有 04-decision.json)",
230
+ );
231
+ lines.push(gateLine(report));
232
+ return lines.join("\n");
233
+ }
234
+
235
+ export function reportRows(reports: readonly RunReport[], ctx: RowContext): string {
236
+ return reports
237
+ .map((report) => {
238
+ // 行上就要能看出「有没有东西」:全部折叠成一行状态+时间,等于用户得逐个点开才知道
239
+ const findings = report.findings.total > 0 ? ` · ${report.findings.total} 条发现` : " · 无发现";
240
+ const proposal = report.proposal ? ` · ${report.proposal.type} ${report.proposal.skill}` : "";
241
+ const verdict = report.review?.verdict ? ` · ${report.review.verdict}` : "";
242
+ // 卡死的 run 与其它状态同样重要,但中文标签才有用:行首就是用户扫的关键词
243
+ const label = report.status === "stale" ? "卡死" : report.status;
244
+ return row(
245
+ `[${label}] ${shortRunId(report.runId)}${findings}${proposal}${verdict} · ${formatStamp(report.startedAt)}`,
246
+ withId(report.runId, reportDetail(report)),
247
+ rowActs(report.runId, ctx, report.status === "stale"),
248
+ report.runId,
249
+ );
250
+ })
251
+ .join("\n");
252
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * run 详情页(`?view=run&runId=...`)的渲染:概要 + 提案 diff + 改后全文 + 评审 + 五闸 + 发现。
3
+ *
4
+ * 为什么 diff 放最前:用户点进这一页 90% 是为了「看看它到底改了什么」。
5
+ * 结论(评审 / 五闸)排在 diff 之后,因为先看结论再看改动的人,一定会回头再看改动。
6
+ *
7
+ * 六块各自的空态都要写明「缺的是哪个文件」—— 一句「暂无数据」会让人以为页面坏了。
8
+ */
9
+ import type { WebState } from "./data.ts";
10
+ import { DIFF_PREVIEW_LINES, renderDiffLines, parseDiffLines } from "./diff-lines.ts";
11
+ import type { RunBlocking, RunDetail } from "./run-detail.ts";
12
+ import { shellPage } from "./shell.ts";
13
+ import { escapeHtml, formatStamp } from "./text.ts";
14
+
15
+ /** 概要的一行:标签 + 值(值已转义) */
16
+ function kv(label: string, value: string): string {
17
+ return `<div class="kv-row"><span class="k">${label}</span><span class="v">${value}</span></div>`;
18
+ }
19
+
20
+ function overview(detail: RunDetail): string {
21
+ const log = detail.hasChildLog
22
+ ? `<a href="/log?runId=${encodeURIComponent(detail.runId)}">child.log</a>`
23
+ : `<span class="off">没有 child.log</span>`;
24
+ return `<section>
25
+ <h2>概要</h2>
26
+ <div class="kv">
27
+ ${kv("runId", `<code>${escapeHtml(detail.runId)}</code>`)}
28
+ ${kv("状态", escapeHtml(detail.status))}
29
+ ${kv("技能", escapeHtml(detail.skill.length > 0 ? detail.skill : "—"))}
30
+ ${kv("触发", escapeHtml(detail.trigger))}
31
+ ${kv("开始", escapeHtml(formatStamp(detail.startedAt)))}
32
+ ${kv("结束", escapeHtml(formatStamp(detail.endedAt)) + (detail.endedAt === null ? "" : ' <span class="hint">(取最后一个产出时刻)</span>'))}
33
+ ${kv("pid", detail.pid === null ? "—" : String(detail.pid))}
34
+ ${kv("日志", log)}
35
+ </div>
36
+ </section>`;
37
+ }
38
+
39
+ /** 提案 diff:逐行统一 diff;超 400 行只渲染前 400 行并给完整下载链接 */
40
+ function diffSection(detail: RunDetail): string {
41
+ if (detail.diff === null) {
42
+ return `<section>
43
+ <h2>提案 diff</h2>
44
+ <p class="empty">这个 run 没有 02-proposal.diff:提案阶段没写出改动(或已被清理)。</p>
45
+ </section>`;
46
+ }
47
+ const lines = parseDiffLines(detail.diff);
48
+ const shown = lines.slice(0, DIFF_PREVIEW_LINES);
49
+ const truncated = lines.length > shown.length;
50
+ const note = truncated
51
+ ? `<p class="hint">diff 共 ${lines.length} 行,页面只渲染前 ${DIFF_PREVIEW_LINES} 行:
52
+ <a class="btn" href="/diff?runId=${encodeURIComponent(detail.runId)}">完整 diff</a></p>`
53
+ : `<p class="hint">diff 共 ${lines.length} 行。</p>`;
54
+ return `<section>
55
+ <h2>提案 diff</h2>
56
+ ${note}
57
+ ${renderDiffLines(shown)}
58
+ </section>`;
59
+ }
60
+
61
+ /** 改后全文:`<details>` 折叠,默认收起(全文可能很长) */
62
+ function candidateSection(detail: RunDetail): string {
63
+ if (detail.candidateText === null) {
64
+ return `<section>
65
+ <h2>改后全文</h2>
66
+ <p class="empty">没有 candidate/${escapeHtml(detail.skill || "<技能名>")}/SKILL.md:这个 run 没留候选文件。</p>
67
+ </section>`;
68
+ }
69
+ const lines = detail.candidateText.split(/\r?\n/).length;
70
+ return `<section>
71
+ <h2>改后全文</h2>
72
+ <details><summary>candidate/${escapeHtml(detail.candidateSkill ?? "")}/SKILL.md(${lines} 行)</summary>
73
+ <pre class="candidate">${escapeHtml(detail.candidateText)}</pre>
74
+ </details>
75
+ </section>`;
76
+ }
77
+
78
+ function blockingRows(blocking: readonly RunBlocking[]): string {
79
+ if (blocking.length === 0) return `<p class="hint">blocking:0 条。</p>`;
80
+ const rows = blocking.map((item) => {
81
+ const where = item.file.length > 0 ? `${item.file}${item.line === null ? "" : `:${item.line}`}` : "(未写位置)";
82
+ return `<tr><td>${escapeHtml(item.issue)}</td><td><code>${escapeHtml(where)}</code></td><td>${escapeHtml(item.quote)}</td></tr>`;
83
+ });
84
+ return `<table class="tbl">
85
+ <thead><tr><th>问题</th><th>证据位置</th><th>原文引用</th></tr></thead>
86
+ <tbody>
87
+ ${rows.join("\n")}
88
+ </tbody>
89
+ </table>`;
90
+ }
91
+
92
+ function reviewSection(detail: RunDetail): string {
93
+ if (detail.review === null) {
94
+ return `<section>
95
+ <h2>评审结论</h2>
96
+ <p class="empty">没有 03-review.json:这一轮还没评审完,或被跳过。</p>
97
+ </section>`;
98
+ }
99
+ const review = detail.review;
100
+ return `<section>
101
+ <h2>评审结论</h2>
102
+ <p class="kv-line">
103
+ verdict <b class="${review.verdict === "pass" ? "ok" : "fail"}">${escapeHtml(review.verdict)}</b> ·
104
+ action <b>${escapeHtml(review.action)}</b> ·
105
+ 状态 ${escapeHtml(review.status)} ·
106
+ 评审模型 <code>${escapeHtml(review.reviewerModel)}</code>
107
+ </p>
108
+ ${blockingRows(detail.blocking)}
109
+ </section>`;
110
+ }
111
+
112
+ function gateSection(detail: RunDetail): string {
113
+ if (detail.gates.length === 0) {
114
+ return `<section>
115
+ <h2>五闸</h2>
116
+ <p class="empty">没有 06-verify.json:五闸未执行(这个 run 没被采纳)。</p>
117
+ </section>`;
118
+ }
119
+ const rows = detail.gates.map(
120
+ (gate) =>
121
+ `<tr><td><code>${escapeHtml(gate.gate)}</code></td><td>${escapeHtml(gate.name)}</td><td class="${gate.ok ? "ok" : "fail"}">${gate.ok ? "ok" : "fail"}</td><td>${escapeHtml(gate.detail)}</td></tr>`,
122
+ );
123
+ return `<section>
124
+ <h2>五闸</h2>
125
+ <p class="hint">验证状态:${escapeHtml(detail.verifyStatus ?? "—")}</p>
126
+ <table class="tbl">
127
+ <thead><tr><th>闸</th><th>名称</th><th>结果</th><th>说明</th></tr></thead>
128
+ <tbody>
129
+ ${rows.join("\n")}
130
+ </tbody>
131
+ </table>
132
+ </section>`;
133
+ }
134
+
135
+ function findingsSection(detail: RunDetail): string {
136
+ if (detail.findingsTotal === 0) {
137
+ return `<section>
138
+ <h2>发现</h2>
139
+ <p class="empty">01-findings.json 里没有条目(这一轮没发现值得改的地方)。</p>
140
+ </section>`;
141
+ }
142
+ const rows = detail.findings.map((item) => {
143
+ const lines = item.lines.length > 0 ? item.lines.join("、") : "—";
144
+ return `<tr><td>${escapeHtml(item.skill)}</td><td>${escapeHtml(item.reason)}</td><td>${escapeHtml(item.confidence)}</td><td>${escapeHtml(lines)}</td></tr>`;
145
+ });
146
+ const more =
147
+ detail.findingsTotal > detail.findings.length
148
+ ? `<p class="hint">只列前 ${detail.findings.length} 条,共 ${detail.findingsTotal} 条(完整内容见 01-findings.json)。</p>`
149
+ : "";
150
+ return `<section>
151
+ <h2>发现</h2>
152
+ <p class="hint">共 ${detail.findingsTotal} 条${detail.notes.length > 0 ? `:${escapeHtml(detail.notes)}` : ""}</p>
153
+ <table class="tbl">
154
+ <thead><tr><th>技能</th><th>归因</th><th>置信度</th><th>证据行</th></tr></thead>
155
+ <tbody>
156
+ ${rows.join("\n")}
157
+ </tbody>
158
+ </table>
159
+ ${more}
160
+ </section>`;
161
+ }
162
+
163
+ export interface RenderRunOptions {
164
+ /** 状态栏要的数字(shell.ts 的 statusBar 唯一依赖) */
165
+ state: WebState;
166
+ /** 返回列表页的链接文案来源:从哪一页点进来的(缺省回待处理) */
167
+ back?: { view: string; label: string };
168
+ }
169
+
170
+ export function renderRunPage(detail: RunDetail, options: RenderRunOptions): string {
171
+ const back = options.back ?? { view: "pending", label: "待处理" };
172
+ const body = `<main class="run">
173
+ <p class="hint"><a href="/?view=${escapeHtml(back.view)}">← 回${escapeHtml(back.label)}</a></p>
174
+ ${overview(detail)}
175
+ ${diffSection(detail)}
176
+ ${candidateSection(detail)}
177
+ ${reviewSection(detail)}
178
+ ${gateSection(detail)}
179
+ ${findingsSection(detail)}
180
+ </main>`;
181
+ return shellPage({
182
+ state: options.state,
183
+ title: `run ${detail.runId}`,
184
+ active: null,
185
+ body,
186
+ foot: `本页只读:diff 与全文来自 <code>runs/${escapeHtml(detail.runId)}</code>,页面不写任何文件。`,
187
+ });
188
+ }