@ai-content-space/loopx 0.1.9 → 0.1.10

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.
@@ -6,21 +6,28 @@ import { nextSkillCommand } from './next-skill.mjs';
6
6
  import { statusSummary } from './workflow.mjs';
7
7
 
8
8
  const WORKFLOW_ARTIFACTS = [
9
- { name: 'spec.md', label: '需求工作副本', page: 'intake.html' },
10
- { name: 'plan.md', label: '计划', page: 'plan.html' },
11
- { name: 'architecture.md', label: '架构', page: 'plan.html' },
12
- { name: 'development-plan.md', label: '开发计划', page: 'plan.html' },
13
- { name: 'test-plan.md', label: '测试计划', page: 'plan.html' },
14
- { name: 'requirement-traceability.md', label: '需求覆盖矩阵', page: 'plan.html' },
15
- { name: 'execution-record.md', label: '执行记录', page: 'build.html' },
16
- { name: 'review-report.md', label: '评审报告', page: 'review.html' },
9
+ { id: 'spec', name: 'spec.md', label: '需求工作副本', page: 'intake.html' },
10
+ { id: 'plan', name: 'plan.md', label: '计划', page: 'plan.html' },
11
+ { id: 'architecture', name: 'architecture.md', label: '架构', page: 'plan.html' },
12
+ { id: 'development-plan', name: 'development-plan.md', label: '开发计划', page: 'plan.html' },
13
+ { id: 'test-plan', name: 'test-plan.md', label: '测试计划', page: 'plan.html' },
14
+ { id: 'requirement-traceability', name: 'requirement-traceability.md', label: '需求覆盖矩阵', page: 'plan.html' },
15
+ { id: 'plan-delegation-decision', name: 'plan-delegation-decision.md', label: '委派决策', page: 'plan.html' },
16
+ { id: 'change-proposal', name: 'proposal.md', label: '变更提案', page: 'change.html', changeKey: 'proposal' },
17
+ { id: 'change-spec-delta', name: 'spec-delta.md', label: '规格增量', page: 'change.html', changeKey: 'specDelta' },
18
+ { id: 'change-design', name: 'design.md', label: '设计方案', page: 'change.html', changeKey: 'design' },
19
+ { id: 'change-tasks', name: 'tasks.md', label: '任务拆解', page: 'change.html', changeKey: 'tasks' },
20
+ { id: 'change-slices', name: 'slices.json', label: '垂直切片', page: 'change.html', changeKey: 'slices' },
21
+ { id: 'execution-record', name: 'execution-record.md', label: '执行记录', page: 'build.html' },
22
+ { id: 'review-report', name: 'review-report.md', label: '评审报告', page: 'review.html' },
17
23
  ];
18
24
 
19
25
  const PAGE_GROUPS = [
20
- { file: 'intake.html', title: '需求澄清', artifacts: ['spec.md'] },
21
- { file: 'plan.html', title: '计划与架构', artifacts: ['plan.md', 'architecture.md', 'development-plan.md', 'test-plan.md', 'requirement-traceability.md'] },
22
- { file: 'build.html', title: '执行与验证', artifacts: ['execution-record.md'] },
23
- { file: 'review.html', title: '评审结论', artifacts: ['review-report.md'] },
26
+ { file: 'intake.html', title: '需求澄清', artifacts: ['spec'] },
27
+ { file: 'plan.html', title: '计划与架构', artifacts: ['plan', 'architecture', 'development-plan', 'test-plan', 'requirement-traceability', 'plan-delegation-decision'] },
28
+ { file: 'change.html', title: '变更设计方案', artifacts: ['change-proposal', 'change-spec-delta', 'change-design', 'change-tasks', 'change-slices'] },
29
+ { file: 'build.html', title: '执行与验证', artifacts: ['execution-record'] },
30
+ { file: 'review.html', title: '评审结论', artifacts: ['review-report'] },
24
31
  ];
25
32
 
26
33
  function escapeHtml(value) {
@@ -41,26 +48,56 @@ function htmlDoc({ title, body }) {
41
48
  ' <meta name="viewport" content="width=device-width, initial-scale=1">',
42
49
  ` <title>${escapeHtml(title)}</title>`,
43
50
  ' <style>',
44
- ' :root { color-scheme: light; --text: #17202a; --muted: #5f6f7f; --line: #d8e0e8; --bg: #f6f8fa; --panel: #ffffff; --accent: #1769aa; }',
51
+ ' :root { color-scheme: light; --text: #17202a; --muted: #5f6f7f; --line: #d8e0e8; --bg: #f6f8fa; --panel: #ffffff; --accent: #1769aa; --ok: #1b7f4d; --warn: #a15c00; --bad: #b42318; --soft: #edf4fb; }',
45
52
  ' * { box-sizing: border-box; }',
46
53
  ' body { margin: 0; font: 15px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--text); background: var(--bg); }',
47
- ' main { max-width: 1080px; margin: 0 auto; padding: 28px 20px 48px; }',
54
+ ' main { max-width: 1180px; margin: 0 auto; padding: 28px 20px 48px; }',
48
55
  ' header { margin-bottom: 20px; }',
49
56
  ' h1 { margin: 0 0 8px; font-size: 28px; line-height: 1.25; }',
50
57
  ' h2 { margin: 24px 0 10px; font-size: 18px; }',
51
58
  ' h3 { margin: 18px 0 8px; font-size: 16px; }',
59
+ ' h4 { margin: 14px 0 6px; font-size: 14px; }',
52
60
  ' a { color: var(--accent); text-decoration: none; }',
53
61
  ' a:hover { text-decoration: underline; }',
54
62
  ' .muted { color: var(--muted); }',
55
63
  ' .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }',
56
64
  ' .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 14px 16px; }',
65
+ ' .panel h2:first-child { margin-top: 0; }',
57
66
  ' .badge { display: inline-block; border: 1px solid var(--line); border-radius: 999px; padding: 2px 9px; margin: 2px 4px 2px 0; color: var(--muted); background: #fff; font-size: 12px; }',
67
+ ' .badge.ok { color: var(--ok); border-color: #b8dfc9; background: #f0fbf4; }',
68
+ ' .badge.warn { color: var(--warn); border-color: #f1d29b; background: #fff8eb; }',
69
+ ' .badge.bad { color: var(--bad); border-color: #f0b7b1; background: #fff1f0; }',
70
+ ' .review-nav { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 18px; }',
71
+ ' .review-nav a { border: 1px solid var(--line); border-radius: 8px; padding: 6px 10px; background: #fff; }',
72
+ ' .callout { border-left: 4px solid var(--accent); background: var(--soft); padding: 10px 12px; margin: 12px 0; }',
73
+ ' .hero { background: linear-gradient(135deg, #fff, #f7fbff); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; margin-bottom: 16px; }',
74
+ ' .hero-grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 14px; }',
75
+ ' .hero-kpi { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-top: 12px; }',
76
+ ' .kpi { border: 1px solid var(--line); background: #fff; border-radius: 10px; padding: 10px 12px; }',
77
+ ' .kpi .label { color: var(--muted); font-size: 12px; }',
78
+ ' .kpi .value { font-size: 18px; font-weight: 700; margin-top: 2px; }',
79
+ ' .layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; align-items: start; }',
80
+ ' .sticky { position: sticky; top: 14px; }',
81
+ ' .stack { display: grid; gap: 12px; }',
82
+ ' .visual-map { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; }',
83
+ ' .visual-card { border: 1px solid var(--line); border-radius: 10px; padding: 12px; background: #fff; }',
84
+ ' .visual-card .title { font-weight: 700; margin-bottom: 4px; }',
85
+ ' .visual-card .meta { color: var(--muted); font-size: 12px; }',
86
+ ' .outline { border-left: 2px solid var(--line); padding-left: 12px; }',
87
+ ' .outline a { display: block; color: var(--text); padding: 3px 0; }',
58
88
  ' table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); }',
59
89
  ' th, td { padding: 8px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }',
60
90
  ' th { color: var(--muted); font-weight: 600; }',
61
91
  ' pre { overflow: auto; padding: 12px; background: #0f1720; color: #edf4fb; border-radius: 8px; }',
62
92
  ' code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }',
63
- ' .markdown { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 16px; }',
93
+ ' .markdown { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 16px; margin-bottom: 16px; }',
94
+ ' .markdown pre { margin: 10px 0 0; }',
95
+ ' .markdown table { margin-top: 10px; }',
96
+ ' .markdown ul, .markdown ol { margin-top: 10px; padding-left: 22px; }',
97
+ ' .markdown p { margin: 10px 0; }',
98
+ ' .markdown h2:first-child, .markdown h3:first-child, .markdown h4:first-child { margin-top: 0; }',
99
+ ' .artifact-meta { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }',
100
+ ' @media (max-width: 860px) { .hero-grid, .layout { grid-template-columns: 1fr; } .sticky { position: static; } }',
64
101
  ' </style>',
65
102
  '</head>',
66
103
  '<body>',
@@ -72,6 +109,184 @@ function htmlDoc({ title, body }) {
72
109
  ].join('\n');
73
110
  }
74
111
 
112
+ function languageMetric(text) {
113
+ const chineseChars = String(text || '').match(/[\u3400-\u9fff]/g) || [];
114
+ const latinChars = String(text || '').match(/[A-Za-z]/g) || [];
115
+ const signalChars = chineseChars.length + latinChars.length;
116
+ const chineseRatio = signalChars === 0 ? 0 : chineseChars.length / signalChars;
117
+ return {
118
+ chineseChars: chineseChars.length,
119
+ latinChars: latinChars.length,
120
+ chineseRatio,
121
+ chineseOk: signalChars > 0 && (chineseChars.length >= 40 || (chineseChars.length >= 8 && chineseRatio >= 0.2)),
122
+ };
123
+ }
124
+
125
+ function artifactMetrics(text) {
126
+ const lines = String(text || '').split('\n');
127
+ const headings = lines.filter((line) => /^#{1,4}\s+/.test(line)).length;
128
+ const tables = lines.filter((line, index) => line.trim().startsWith('|') && lines[index + 1] && /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[index + 1])).length;
129
+ const todoCount = lines.filter((line) => /\b(TBD|TODO|REPLACE_ME)\b/i.test(line)).length;
130
+ return {
131
+ lines: lines.filter((line) => line.trim()).length,
132
+ headings,
133
+ tables,
134
+ todoCount,
135
+ ...languageMetric(text),
136
+ };
137
+ }
138
+
139
+ function artifactId(artifact) {
140
+ return artifact.id || artifact.name;
141
+ }
142
+
143
+ function anchorForArtifact(artifact) {
144
+ return artifactId(artifact).replace(/[^a-z0-9-]/gi, '-');
145
+ }
146
+
147
+ function resolveArtifactPath(root, state, artifact) {
148
+ if (artifact.changeKey) {
149
+ return state.change_artifact_paths?.[artifact.changeKey] || null;
150
+ }
151
+ return join(root, artifact.name);
152
+ }
153
+
154
+ function statusBadge(label, status) {
155
+ const normalized = String(status ?? '').toLowerCase();
156
+ const cls = ['true', 'complete', 'approved', 'written', 'ready', 'exists', 'ok'].includes(normalized)
157
+ ? 'ok'
158
+ : ['false', 'missing', 'blocked', 'partial', 'failed', 'none', 'needs-review'].includes(normalized)
159
+ ? 'bad'
160
+ : 'warn';
161
+ return `<span class="badge ${cls}">${escapeHtml(label)}: ${escapeHtml(status)}</span>`;
162
+ }
163
+
164
+ function extractHeadings(text) {
165
+ return String(text || '')
166
+ .split('\n')
167
+ .map((line) => line.match(/^(#{1,4})\s+(.+?)\s*$/))
168
+ .filter(Boolean)
169
+ .map((match) => ({
170
+ level: match[1].length,
171
+ title: match[2].replace(/`/g, '').trim(),
172
+ }));
173
+ }
174
+
175
+ function firstParagraph(text) {
176
+ return String(text || '')
177
+ .split('\n')
178
+ .map((line) => line.trim())
179
+ .filter(Boolean)
180
+ .filter((line) => !line.startsWith('#'))
181
+ .filter((line) => !line.startsWith('|'))
182
+ .filter((line) => !/^[-*]\s+/.test(line))
183
+ .filter((line) => !/^\d+[.)]\s+/.test(line))
184
+ .find((line) => line.length > 20) || '';
185
+ }
186
+
187
+ function sectionDigest(text) {
188
+ const headings = extractHeadings(text);
189
+ return {
190
+ title: headings[0]?.title || firstParagraph(text) || '无标题产物',
191
+ outline: headings.filter((item) => item.level <= 3).slice(0, 8),
192
+ summary: firstParagraph(text),
193
+ };
194
+ }
195
+
196
+ function collectHeadingBlocks(text) {
197
+ const lines = String(text || '').split('\n');
198
+ const blocks = [];
199
+ let current = null;
200
+ for (const line of lines) {
201
+ const heading = line.match(/^(#{1,4})\s+(.+?)\s*$/);
202
+ if (heading) {
203
+ if (current) {
204
+ blocks.push(current);
205
+ }
206
+ current = {
207
+ level: heading[1].length,
208
+ title: heading[2].replace(/`/g, '').trim(),
209
+ lines: [],
210
+ };
211
+ continue;
212
+ }
213
+ if (current) {
214
+ current.lines.push(line);
215
+ }
216
+ }
217
+ if (current) {
218
+ blocks.push(current);
219
+ }
220
+ return blocks;
221
+ }
222
+
223
+ function blockPreview(block) {
224
+ return block.lines
225
+ .map((line) => line.trim())
226
+ .filter(Boolean)
227
+ .find((line) => !line.startsWith('#') && !line.startsWith('|') && !/^[-*]\s+/.test(line) && !/^\d+[.)]\s+/.test(line)) || '';
228
+ }
229
+
230
+ function parseFrontmatterBlock(text) {
231
+ if (!String(text || '').startsWith('---\n')) {
232
+ return {};
233
+ }
234
+ const end = String(text).indexOf('\n---\n', 4);
235
+ if (end === -1) {
236
+ return {};
237
+ }
238
+ return Object.fromEntries(
239
+ String(text)
240
+ .slice(4, end)
241
+ .split('\n')
242
+ .map((line) => line.trim())
243
+ .filter(Boolean)
244
+ .map((line) => {
245
+ const separator = line.indexOf(':');
246
+ const key = line.slice(0, separator).trim();
247
+ const rawValue = line.slice(separator + 1).trim();
248
+ if (rawValue.startsWith('[') || rawValue.startsWith('{')) {
249
+ try {
250
+ return [key, JSON.parse(rawValue)];
251
+ } catch {
252
+ return [key, rawValue];
253
+ }
254
+ }
255
+ return [key, rawValue];
256
+ }),
257
+ );
258
+ }
259
+
260
+ function renderListItems(lines) {
261
+ const items = lines
262
+ .map((line) => line.trim())
263
+ .filter((line) => /^[-*]\s+/.test(line) || /^\d+[.)]\s+/.test(line))
264
+ .map((line) => line.replace(/^[-*]\s+/, '').replace(/^\d+[.)]\s+/, '').trim())
265
+ .filter(Boolean);
266
+ return items.length > 0 ? `<ul>${items.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul>` : '<span class="muted">无</span>';
267
+ }
268
+
269
+ function renderKeyValueTable(entries) {
270
+ return `<table><tbody>${entries.map(([key, value]) => `<tr><th>${escapeHtml(key)}</th><td>${value}</td></tr>`).join('')}</tbody></table>`;
271
+ }
272
+
273
+ function summarizeRequirementBlocks(text) {
274
+ const matches = [...String(text || '').matchAll(/^###\s+Requirement:\s*(.+?)\s*$/gim)];
275
+ return matches.map((match) => ({
276
+ name: match[1].trim(),
277
+ preview: '',
278
+ }));
279
+ }
280
+
281
+ function parseSlicesJson(text) {
282
+ try {
283
+ const payload = JSON.parse(text);
284
+ return Array.isArray(payload.slices) ? payload.slices : [];
285
+ } catch {
286
+ return [];
287
+ }
288
+ }
289
+
75
290
  function listItems(items) {
76
291
  const values = Array.isArray(items) ? items.filter(Boolean) : [];
77
292
  if (values.length === 0) {
@@ -186,11 +401,137 @@ function markdownToHtml(markdown) {
186
401
  return html.join('\n');
187
402
  }
188
403
 
404
+ function artifactDetailHtml(artifact, text) {
405
+ const blocks = collectHeadingBlocks(text);
406
+ const topBlocks = blocks.filter((block) => block.level <= 3).slice(0, 12);
407
+ const sectionCards = topBlocks.length > 0
408
+ ? [
409
+ '<section class="panel">',
410
+ '<h3>章节速览</h3>',
411
+ '<div class="visual-map">',
412
+ ...topBlocks.map((block) => [
413
+ '<div class="visual-card">',
414
+ `<div class="title">${escapeHtml(block.title)}</div>`,
415
+ `<div class="meta">${escapeHtml(blockPreview(block) || '该章节主要由列表、表格或代码块组成。')}</div>`,
416
+ '</div>',
417
+ ].join('')),
418
+ '</div>',
419
+ '</section>',
420
+ ].join('\n')
421
+ : '';
422
+
423
+ if (artifact.name === 'spec-delta.md') {
424
+ const frontmatter = parseFrontmatterBlock(text);
425
+ const requirements = summarizeRequirementBlocks(text);
426
+ return [
427
+ '<section class="panel">',
428
+ '<h3>规格增量详解</h3>',
429
+ renderKeyValueTable([
430
+ ['change_id', escapeHtml(frontmatter.change_id || '(未声明)')],
431
+ ['slug', escapeHtml(frontmatter.slug || '(未声明)')],
432
+ ['target_domains', escapeHtml(Array.isArray(frontmatter.target_domains) ? frontmatter.target_domains.join(', ') : (frontmatter.target_domains || '(见正文)'))],
433
+ ['requirement_blocks', escapeHtml(requirements.length)],
434
+ ]),
435
+ requirements.length > 0 ? [
436
+ '<h4>Requirement Blocks</h4>',
437
+ '<div class="visual-map">',
438
+ ...requirements.map((item) => `<div class="visual-card"><div class="title">${escapeHtml(item.name)}</div><div class="meta">包含 SHALL/MUST 与 Scenario 的归档候选需求块。</div></div>`),
439
+ '</div>',
440
+ ].join('\n') : '',
441
+ '</section>',
442
+ sectionCards,
443
+ ].join('\n');
444
+ }
445
+
446
+ if (artifact.name === 'slices.json') {
447
+ const slices = parseSlicesJson(text);
448
+ return [
449
+ '<section class="panel">',
450
+ '<h3>垂直切片详解</h3>',
451
+ '<div class="visual-map">',
452
+ ...(slices.length > 0 ? slices.map((slice) => [
453
+ '<div class="visual-card">',
454
+ `<div class="title">${escapeHtml(slice.id || '(no id)')} ${escapeHtml(slice.title || slice.name || '')}</div>`,
455
+ `<div class="meta">type: ${escapeHtml(slice.type || 'unknown')}</div>`,
456
+ `<p>${escapeHtml(slice.behavior || slice.description || '')}</p>`,
457
+ '<h4>验收</h4>',
458
+ renderListItems(Array.isArray(slice.acceptance_criteria) ? slice.acceptance_criteria.map((item) => `- ${item}`) : (Array.isArray(slice.acceptance) ? slice.acceptance.map((item) => `- ${item}`) : [])),
459
+ `<p class="muted">verification: ${escapeHtml(slice.verification_signal || '(missing)')}</p>`,
460
+ '</div>',
461
+ ].join('')) : ['<span class="muted">未解析到 slices。</span>']),
462
+ '</div>',
463
+ '</section>',
464
+ ].join('\n');
465
+ }
466
+
467
+ if (artifact.name === 'tasks.md') {
468
+ const checklist = String(text || '').split('\n').filter((line) => /^\s*-\s+\[[ xX]\]\s+/.test(line));
469
+ return [
470
+ '<section class="panel">',
471
+ '<h3>任务拆解详解</h3>',
472
+ renderKeyValueTable([
473
+ ['checklist_items', escapeHtml(checklist.length)],
474
+ ['open_items', escapeHtml(checklist.filter((line) => /\[\s\]/.test(line)).length)],
475
+ ['completed_items', escapeHtml(checklist.filter((line) => /\[[xX]\]/.test(line)).length)],
476
+ ]),
477
+ '</section>',
478
+ sectionCards,
479
+ ].join('\n');
480
+ }
481
+
482
+ return sectionCards;
483
+ }
484
+
189
485
  function artifactLink(viewRoot, artifactPath, label) {
190
486
  const href = relative(viewRoot, artifactPath).replaceAll('\\', '/');
191
487
  return `<a href="${escapeHtml(href)}">${escapeHtml(label)}</a>`;
192
488
  }
193
489
 
490
+ function approvalPanels(state) {
491
+ const approval = state.approval || {};
492
+ const slug = state.slug || '(slug)';
493
+ const transitions = [
494
+ ['clarify -> plan', approval.plan || 'not-requested', `loopx approve ${slug} --from clarify --to plan`],
495
+ ['plan -> build', approval.build || 'not-requested', `loopx approve ${slug} --from plan --to build`],
496
+ ['build -> review', approval.review || 'not-requested', `loopx approve ${slug} --from build --to review`],
497
+ ['review -> done', approval.complete || 'not-requested', `loopx approve ${slug} --from review --to done`],
498
+ ];
499
+ return [
500
+ '<section class="panel">',
501
+ '<h2>人工确认点</h2>',
502
+ '<table><thead><tr><th>阶段流转</th><th>授权状态</th><th>命令</th></tr></thead><tbody>',
503
+ ...transitions.map(([label, status, command]) => [
504
+ '<tr>',
505
+ `<td>${escapeHtml(label)}</td>`,
506
+ `<td>${statusBadge('approval', status)}</td>`,
507
+ `<td><code>${escapeHtml(command)}</code></td>`,
508
+ '</tr>',
509
+ ].join('')),
510
+ '</tbody></table>',
511
+ '</section>',
512
+ ].join('\n');
513
+ }
514
+
515
+ function planGateSummary(state) {
516
+ const gates = [
517
+ ['Planner / Architect / Critic', `${state.plan_architect_review_status || 'not-started'} / ${state.plan_critic_verdict || 'none'}`],
518
+ ['中文规划文档', state.plan_docs_status || 'missing'],
519
+ ['需求覆盖矩阵', state.source_requirements_status || 'unknown'],
520
+ ['变更工件', state.change_artifacts_status || 'missing'],
521
+ ['Spec Delta', state.spec_delta_status || 'missing'],
522
+ ['Vertical Slices', state.slice_artifacts_status || 'missing'],
523
+ ['委派决策', state.plan_delegation_mode || 'unknown'],
524
+ ];
525
+ return [
526
+ '<section class="panel">',
527
+ '<h2>Plan 审阅门禁</h2>',
528
+ '<table><thead><tr><th>门禁</th><th>状态</th></tr></thead><tbody>',
529
+ ...gates.map(([label, value]) => `<tr><td>${escapeHtml(label)}</td><td>${escapeHtml(value)}</td></tr>`),
530
+ '</tbody></table>',
531
+ '</section>',
532
+ ].join('\n');
533
+ }
534
+
194
535
  function statusPanels(status) {
195
536
  const state = status.state || {};
196
537
  const readiness = state.readiness || {};
@@ -204,6 +545,8 @@ function statusPanels(status) {
204
545
  `<div class="panel"><strong>下一步</strong><br><code>${escapeHtml(nextSkill || status.next_action || 'none')}</code></div>`,
205
546
  `<div class="panel"><strong>归档</strong><br>${escapeHtml(state.archive_status || 'pending')}</div>`,
206
547
  '</section>',
548
+ approvalPanels(state),
549
+ state.current_stage === 'plan' || state.plan_package_status ? planGateSummary(state) : '',
207
550
  '<section class="panel">',
208
551
  '<h2>readiness / authorization</h2>',
209
552
  '<table><thead><tr><th>关卡</th><th>ready</th><th>authorized</th><th>blockers</th></tr></thead><tbody>',
@@ -226,26 +569,73 @@ async function renderWorkflowPages(status) {
226
569
  await mkdir(viewRoot, { recursive: true });
227
570
 
228
571
  const artifactRows = WORKFLOW_ARTIFACTS.map((artifact) => {
229
- const artifactPath = join(root, artifact.name);
572
+ const artifactPath = resolveArtifactPath(root, status.state || {}, artifact);
230
573
  return {
231
574
  ...artifact,
232
575
  path: artifactPath,
233
- exists: existsSync(artifactPath),
576
+ exists: Boolean(artifactPath) && existsSync(artifactPath),
234
577
  };
235
578
  });
236
579
 
580
+ const renderArtifactBody = async (artifact) => {
581
+ const text = await readFile(artifact.path, 'utf8');
582
+ const metrics = artifactMetrics(text);
583
+ const digest = sectionDigest(text);
584
+ const isJson = artifact.name.endsWith('.json');
585
+ const detailHtml = artifactDetailHtml(artifact, text);
586
+ const outline = digest.outline.length > 0
587
+ ? `<div class="outline">${digest.outline.map((item) => `<span class="badge">${escapeHtml('H'.repeat(item.level))} ${escapeHtml(item.title)}</span>`).join(' ')}</div>`
588
+ : '<span class="muted">无可提取标题</span>';
589
+ return {
590
+ text,
591
+ metrics,
592
+ digest,
593
+ detailHtml,
594
+ body: isJson
595
+ ? `<pre><code>${escapeHtml(text)}</code></pre>`
596
+ : markdownToHtml(text),
597
+ meta: [
598
+ statusBadge('产物', artifact.name),
599
+ statusBadge('中文', metrics.chineseOk ? 'ok' : 'needs-review'),
600
+ statusBadge('行数', metrics.lines),
601
+ statusBadge('标题', metrics.headings),
602
+ statusBadge('表格', metrics.tables),
603
+ metrics.todoCount > 0 ? statusBadge('占位符', metrics.todoCount) : '',
604
+ ].join(''),
605
+ digestHtml: outline,
606
+ };
607
+ };
608
+
237
609
  for (const group of PAGE_GROUPS) {
238
610
  const sections = [];
239
- for (const artifactName of group.artifacts) {
240
- const artifact = artifactRows.find((item) => item.name === artifactName);
611
+ const availableArtifacts = artifactRows.filter((artifact) => group.artifacts.includes(artifactId(artifact)) && artifact.exists);
612
+ const nav = availableArtifacts.length > 0
613
+ ? `<nav class="review-nav">${availableArtifacts.map((artifact) => `<a href="#${escapeHtml(anchorForArtifact(artifact))}">${escapeHtml(artifact.label)}</a>`).join('')}</nav>`
614
+ : '';
615
+ for (const artifactIdValue of group.artifacts) {
616
+ const artifact = artifactRows.find((item) => artifactId(item) === artifactIdValue);
241
617
  if (!artifact?.exists) {
242
618
  continue;
243
619
  }
244
- const text = await readFile(artifact.path, 'utf8');
620
+ const rendered = await renderArtifactBody(artifact);
245
621
  sections.push([
246
- `<section class="markdown">`,
622
+ `<section class="markdown" id="${escapeHtml(anchorForArtifact(artifact))}">`,
623
+ '<div class="hero-grid">',
624
+ '<div>',
625
+ `<h2>${escapeHtml(artifact.label)}</h2>`,
247
626
  `<p class="muted">${artifactLink(viewRoot, artifact.path, artifact.name)}</p>`,
248
- markdownToHtml(text),
627
+ `<p>${escapeHtml(rendered.digest.summary || '该产物没有单独摘要,直接阅读正文。')}</p>`,
628
+ '</div>',
629
+ '<div class="stack">',
630
+ rendered.meta,
631
+ `<div class="panel"><strong>结构预览</strong><div class="outline">${rendered.digestHtml}</div></div>`,
632
+ '</div>',
633
+ '</div>',
634
+ '<div class="artifact-meta">',
635
+ rendered.meta,
636
+ '</div>',
637
+ rendered.detailHtml,
638
+ rendered.body,
249
639
  '</section>',
250
640
  ].join('\n'));
251
641
  }
@@ -255,30 +645,68 @@ async function renderWorkflowPages(status) {
255
645
  '<header>',
256
646
  `<h1>${escapeHtml(group.title)}</h1>`,
257
647
  `<p class="muted">工作流:${escapeHtml(status.slug)}</p>`,
648
+ '<div class="callout">这是人工审阅入口。先看上方视觉摘要,再按导航逐项审阅正文;HTML 负责可视化阅读,原始文件仍可点击查看。</div>',
258
649
  '<p><a href="index.html">返回工作流首页</a></p>',
650
+ nav,
259
651
  '</header>',
260
652
  sections.length > 0 ? sections.join('\n') : '<section class="panel muted">暂无对应产物。</section>',
261
653
  ].join('\n'),
262
654
  }));
263
655
  }
264
656
 
265
- const indexBody = [
266
- '<header>',
657
+ const artifactTableRows = await Promise.all(artifactRows.map(async (artifact) => {
658
+ if (!artifact.exists) {
659
+ return [
660
+ '<tr>',
661
+ `<td>${escapeHtml(artifact.label)}</td>`,
662
+ '<td>缺失</td>',
663
+ '<td><span class="muted">无</span></td>',
664
+ '<td><span class="muted">无</span></td>',
665
+ '<td><span class="muted">无</span></td>',
666
+ '<td><span class="muted">无</span></td>',
667
+ '</tr>',
668
+ ].join('');
669
+ }
670
+ const rendered = await renderArtifactBody(artifact);
671
+ return [
672
+ '<tr>',
673
+ `<td>${escapeHtml(artifact.label)}</td>`,
674
+ '<td>存在</td>',
675
+ `<td>${artifact.page ? `<a href="${escapeHtml(artifact.page)}">${escapeHtml(basename(artifact.page))}</a>` : '<span class="muted">无</span>'}</td>`,
676
+ `<td>${rendered.meta}</td>`,
677
+ `<td>${escapeHtml(rendered.metrics.headings)} 标题 / ${escapeHtml(rendered.metrics.tables)} 表格 / ${escapeHtml(rendered.metrics.lines)} 行</td>`,
678
+ `<td>${artifactLink(viewRoot, artifact.path, artifact.name)}</td>`,
679
+ '</tr>',
680
+ ].join('');
681
+ }));
682
+
683
+ const heroSummary = [
684
+ `<div class="hero">`,
685
+ '<div class="hero-grid">',
686
+ '<div>',
267
687
  `<h1>工作流 ${escapeHtml(status.slug)}</h1>`,
268
688
  `<p class="muted">HTML 是派生阅读视图;Markdown 和 JSON 仍是运行时事实源。</p>`,
269
- '</header>',
689
+ '<div class="hero-kpi">',
690
+ `<div class="kpi"><div class="label">阶段</div><div class="value">${escapeHtml(status.state?.current_stage || '(none)')}</div></div>`,
691
+ `<div class="kpi"><div class="label">状态</div><div class="value">${escapeHtml(status.state?.stage_status || '(unknown)')}</div></div>`,
692
+ `<div class="kpi"><div class="label">中文产物</div><div class="value">${escapeHtml(status.state?.plan_docs_status || 'unknown')}</div></div>`,
693
+ `<div class="kpi"><div class="label">下一步</div><div class="value">${escapeHtml(status.next_action || 'none')}</div></div>`,
694
+ '</div>',
695
+ '</div>',
696
+ '<div class="stack">',
697
+ approvalPanels(status.state || {}),
698
+ '</div>',
699
+ '</div>',
700
+ '</div>',
701
+ ].join('\n');
702
+
703
+ const indexBody = [
704
+ heroSummary,
270
705
  statusPanels(status),
271
706
  '<section class="panel">',
272
- '<h2>关键产物</h2>',
273
- '<table><thead><tr><th>产物</th><th>状态</th><th>阅读视图</th><th>原始文件</th></tr></thead><tbody>',
274
- ...artifactRows.map((artifact) => [
275
- '<tr>',
276
- `<td>${escapeHtml(artifact.label)}</td>`,
277
- `<td>${artifact.exists ? '存在' : '缺失'}</td>`,
278
- `<td>${artifact.exists ? `<a href="${escapeHtml(artifact.page)}">${escapeHtml(basename(artifact.page))}</a>` : '<span class="muted">无</span>'}</td>`,
279
- `<td>${artifact.exists ? artifactLink(viewRoot, artifact.path, artifact.name) : '<span class="muted">无</span>'}</td>`,
280
- '</tr>',
281
- ].join('')),
707
+ '<h2>关键产物审阅清单</h2>',
708
+ '<table><thead><tr><th>产物</th><th>状态</th><th>阅读视图</th><th>中文</th><th>结构</th><th>原始文件</th></tr></thead><tbody>',
709
+ ...artifactTableRows,
282
710
  '</tbody></table>',
283
711
  '</section>',
284
712
  ].join('\n');
@@ -363,7 +363,8 @@ export function createRealPlanAdapter({ model } = {}) {
363
363
  '}',
364
364
  `Deliberate mode: ${Boolean(context.deliberateMode)}`,
365
365
  '',
366
- 'Use Chinese for planText / architectureText / developmentPlanText / testPlanText.',
366
+ 'planText, architectureText, developmentPlanText, and testPlanText MUST be written in Chinese for human review. Do not write English headings or English prose except literal code paths, API names, commands, enum values, and product terms.',
367
+ 'Make the artifacts reviewable: use clear Chinese section headings, concise tables for coverage/options/risks, and explicit approval-ready handoff sections. Avoid generic filler.',
367
368
  'Treat the source requirements/PRD as the source of truth. Explicitly cover every named event, field, processing mode, table row, and acceptance item that appears in the source, or clearly mark it out of scope with rationale.',
368
369
  'If previous review feedback is present, revise the plan to explicitly resolve it. Do not repeat the same plan unchanged.',
369
370
  'Do not ask questions. Do not wrap JSON in markdown.',