@ai-content-space/loopx 0.1.3 → 0.1.4

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.
@@ -40,7 +40,7 @@ By default, `plan` includes the full consensus review loop formerly documented u
40
40
  Accepted inputs:
41
41
 
42
42
  - an approved loopx clarify workflow slug
43
- - `.loopx/specs/clarify-*.md`
43
+ - `.loopx/intake/clarify-*.md`
44
44
  - `.omx/specs/deep-interview-*.md`
45
45
  - a direct task description when enough context is already present
46
46
  - `--direct <spec-path>` to force a specific requirements artifact
@@ -174,7 +174,7 @@ The final plan must include:
174
174
 
175
175
  - ADR: Decision, Drivers, Alternatives considered, Why chosen, Consequences, Follow-ups
176
176
  - concrete implementation steps sized to the actual task
177
- - target long-lived spec domains and the requirements delta for archive
177
+ - target long-lived spec domains and an OpenSpec-style requirements delta for archive
178
178
  - vertical slices sized as independently verifiable tracer bullets, not horizontal layer-only task groups
179
179
  - execution inputs mapped to concrete sources before build starts
180
180
  - available execution lanes and recommended lane
@@ -220,7 +220,8 @@ The plan gate is blocked until:
220
220
 
221
221
  - plan package artifacts exist
222
222
  - change proposal, spec delta, design, tasks, vertical slices, and artifact graph exist
223
- - spec delta declares target domains and added or modified requirements
223
+ - spec delta declares target domains and `## ADDED|MODIFIED|REMOVED|RENAMED Requirements` blocks
224
+ - every ADDED or MODIFIED requirement uses `### Requirement:`, contains SHALL or MUST text, and includes at least one `#### Scenario:`
224
225
  - vertical slices contain at least one `AFK` or `HITL` end-to-end slice with acceptance criteria and verification signal
225
226
  - Architect review is complete
226
227
  - Critic verdict is `approve`
@@ -3,7 +3,6 @@
3
3
  import { existsSync, readdirSync } from 'node:fs';
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { dirname, join, resolve } from 'node:path';
6
- import { nextSkillCommand } from '../src/next-skill.mjs';
7
6
 
8
7
  function readStdin() {
9
8
  return new Promise((resolveValue) => {
@@ -29,9 +28,61 @@ function readStdin() {
29
28
  }
30
29
 
31
30
  function nextSkill(state) {
32
- const command = nextSkillCommand(state);
33
- if (command) {
34
- return command;
31
+ if (!state || !state.slug) {
32
+ return null;
33
+ }
34
+ const reviewBuildCommand = `$build --from-review .loopx/workflows/${state.slug}/review-report.md`;
35
+ if (isClarifyReadyForPlan(state)) {
36
+ return `$plan ${state.slug}`;
37
+ }
38
+ if (state.current_stage === 'done'
39
+ && state.completion_confirmed === true
40
+ && state.archive_status !== 'archived') {
41
+ return `$archive ${state.slug}`;
42
+ }
43
+ if (state.stage_status === 'awaiting-approval'
44
+ && state.current_stage === 'plan'
45
+ && Array.isArray(state.plan_blockers)
46
+ && state.plan_blockers.length === 0) {
47
+ return `$build .loopx/plans/prd-${state.slug}.md`;
48
+ }
49
+ if (state.current_stage === 'build'
50
+ && state.stage_status === 'awaiting-approval'
51
+ && state.pending_user_decision === 'build->review'
52
+ && state.review_status === 'ready-for-review'
53
+ && state.execution_record_status === 'complete'
54
+ && Array.isArray(state.build_blockers)
55
+ && state.build_blockers.length === 0) {
56
+ return `$review .loopx/workflows/${state.slug}/execution-record.md`;
57
+ }
58
+ if (state.current_stage === 'review'
59
+ && state.review_verdict === 'request-changes'
60
+ && state.rollback_target === 'build'
61
+ && (
62
+ state.pending_user_decision === 'review->build'
63
+ || state.requested_transition === 'review->build'
64
+ || state.approval?.build === 'requested'
65
+ || state.approval?.build === 'approved'
66
+ )) {
67
+ return reviewBuildCommand;
68
+ }
69
+ if (state.current_stage === 'review'
70
+ && state.review_verdict === 'request-changes'
71
+ && state.requested_transition === 'review->build'
72
+ && state.approval?.build === 'approved') {
73
+ return reviewBuildCommand;
74
+ }
75
+ if (state.current_stage === 'review'
76
+ && state.review_verdict === 'request-changes'
77
+ && state.requested_transition === 'review->plan'
78
+ && state.approval?.rollback === 'approved') {
79
+ return `$plan ${state.slug}`;
80
+ }
81
+ if (state.current_stage === 'review'
82
+ && state.review_verdict === 'request-changes'
83
+ && state.requested_transition === 'review->clarify'
84
+ && state.approval?.rollback === 'approved') {
85
+ return `$clarify ${state.slug}`;
35
86
  }
36
87
  if (state.current_stage === 'review' && state.review_verdict === 'approve' && state.pending_user_decision === 'review->done') {
37
88
  return `loopx approve ${state.slug} --from review --to done`;
@@ -59,6 +110,49 @@ function stateLine(key, value) {
59
110
  return `${key}: ${value ?? 'unknown'}`;
60
111
  }
61
112
 
113
+ function isClarifyReadyForPlan(state) {
114
+ return (state.current_stage === 'clarify' || (!state.current_stage && typeof state.clarify_current_round === 'number'))
115
+ && state.clarify_current_round > 0
116
+ && state.unresolved_ambiguity_count === 0
117
+ && state.clarify_non_goals_resolved === true
118
+ && state.clarify_decision_boundaries_resolved === true
119
+ && state.clarify_pressure_pass_complete === true
120
+ && typeof state.clarify_ambiguity_score === 'number'
121
+ && typeof state.clarify_target_ambiguity_threshold === 'number'
122
+ && state.clarify_ambiguity_score <= state.clarify_target_ambiguity_threshold;
123
+ }
124
+
125
+ function isLegacyClarifyState(state) {
126
+ return !state.current_stage && typeof state.clarify_current_round === 'number';
127
+ }
128
+
129
+ function nextActionLine(state, workflow) {
130
+ if (isLegacyClarifyState(state) && isClarifyReadyForPlan(state)) {
131
+ return `loopx migrate, then $plan ${state.slug || workflow}`;
132
+ }
133
+ if (isClarifyReadyForPlan(state) && state.approval?.plan !== 'approved') {
134
+ return `approve clarify -> plan, then $plan ${state.slug || workflow}`;
135
+ }
136
+ return nextSkill(state) || state.recommended_next_action || 'none';
137
+ }
138
+
139
+ function implementationGateLines(state) {
140
+ if (isClarifyReadyForPlan(state) && state.approval?.build !== 'approved') {
141
+ return [
142
+ 'implementation gate: blocked until plan is approved',
143
+ 'do not start build, TDD, or code edits from clarify',
144
+ ];
145
+ }
146
+ return [];
147
+ }
148
+
149
+ function stageText(state) {
150
+ if (isLegacyClarifyState(state)) {
151
+ return `legacy-clarify (${isClarifyReadyForPlan(state) ? 'blocked' : 'incomplete'})`;
152
+ }
153
+ return `${state.current_stage || 'unknown'} (${state.stage_status || 'unknown'})`;
154
+ }
155
+
62
156
  function evidenceLines(state) {
63
157
  const evidence = Array.isArray(state.current_evidence_chain) ? state.current_evidence_chain : [];
64
158
  if (evidence.length === 0) {
@@ -131,9 +225,10 @@ try {
131
225
  '</loopx_instructions>',
132
226
  '<loopx_state>',
133
227
  `loopx workflow: ${state.slug || workflow}`,
134
- `stage: ${state.current_stage || 'unknown'} (${state.stage_status || 'unknown'})`,
135
- `next: ${nextSkill(state) || state.recommended_next_action || 'none'}`,
228
+ `stage: ${stageText(state)}`,
229
+ `next: ${nextActionLine(state, workflow)}`,
136
230
  `blockers: ${blockers(state)}`,
231
+ ...implementationGateLines(state),
137
232
  `approval: ${JSON.stringify(state.approval || {})}`,
138
233
  stateLine('readiness.plan.ready', boolText(state.readiness?.plan?.ready)),
139
234
  stateLine('readiness.build.ready', boolText(state.readiness?.build?.ready)),
@@ -10,6 +10,15 @@ argument-hint: "<workflow slug>"
10
10
 
11
11
  Use `archive` after a loopx workflow has reached `done`. It syncs the accepted change delta into long-lived `.loopx/specs/` files, archives the change staging directory, and writes an advisory ADR candidate.
12
12
 
13
+ The accepted delta is requirement-based, not a changelog block. Archive applies:
14
+
15
+ - `## ADDED Requirements`
16
+ - `## MODIFIED Requirements`
17
+ - `## REMOVED Requirements`
18
+ - `## RENAMED Requirements`
19
+
20
+ into the current long-lived `## Requirements` state for each target domain.
21
+
13
22
  ## Inputs
14
23
 
15
24
  - `<workflow slug>` for a completed loopx workflow
@@ -33,6 +42,7 @@ Then report in Chinese:
33
42
  ## Boundaries
34
43
 
35
44
  - Do not run archive before `review -> done` has been approved.
45
+ - Do not archive malformed requirement deltas. ADDED and MODIFIED entries must use `### Requirement:`, SHALL/MUST language, and at least one `#### Scenario:`.
36
46
  - Do not archive when `execution-record.md` declares non-empty `remaining_scope`, `completion_claim` other than `full`, or a mismatch between `planned_scope` and `implemented_scope`; route back to build/plan instead.
37
47
  - Do not edit implementation code.
38
48
  - Do not promote ADR candidates into `docs/adr/` automatically; report the candidate path for human follow-up.
@@ -23,7 +23,7 @@ Its job is not just to ask questions. Its job is to turn a vague or overloaded r
23
23
  - The request is broad, ambiguous, or mixes problem, solution, and implementation detail.
24
24
  - The user needs help defining scope, non-goals, acceptance criteria, or tradeoffs before planning.
25
25
  - A design direction exists only implicitly and would otherwise be invented during implementation.
26
- - The task will later be handed to `plan`, `build`, or `autopilot`, and you want a high-signal spec first.
26
+ - The task will later be handed to `plan`, and you want a high-signal spec first.
27
27
  </Use_When>
28
28
 
29
29
  <Do_Not_Use_When>
@@ -33,7 +33,7 @@ Its job is not just to ask questions. Its job is to turn a vague or overloaded r
33
33
  </Do_Not_Use_When>
34
34
 
35
35
  <Why_This_Exists>
36
- Most implementation drift happens before coding begins. Teams often think they need “more planning,” when the real problem is weaker intent clarity, hidden assumptions, fuzzy boundaries, or a design shape that was never made explicit. `clarify` exists to solve those upstream failures before `plan` or `build` magnifies them.
36
+ Most implementation drift happens before coding begins. Teams often think they need “more planning,” when the real problem is weaker intent clarity, hidden assumptions, fuzzy boundaries, or a design shape that was never made explicit. `clarify` exists to solve those upstream failures before `plan` turns the clarified intent into an execution contract.
37
37
  </Why_This_Exists>
38
38
 
39
39
  <Core_Principles>
@@ -268,7 +268,7 @@ Before marking a clarify spec handoff-ready, perform a self-review pass:
268
268
 
269
269
  Write the output to the loopx runtime namespace:
270
270
 
271
- - `.loopx/specs/clarify-<slug>-<timestamp>.md`
271
+ - `.loopx/intake/clarify-<slug>-<timestamp>.md`
272
272
 
273
273
  The clarify spec should include:
274
274
 
@@ -299,17 +299,17 @@ The clarify spec should include:
299
299
 
300
300
  After the clarify spec is ready:
301
301
 
302
- - hand off to `plan` by default
303
- - hand off to `build` only if the user explicitly wants direct execution and the task is already concrete enough
304
- - hand off to `autopilot` only when the scope is sufficiently tight for a bounded end-to-end run
302
+ - hand off to `plan`; do not start implementation, TDD, `build`, or `autopilot` from `clarify`
303
+ - if the user asks to execute immediately, explain that loopx requires the `plan` gate first and provide the plan invocation
304
+ - if a task is too small to justify planning, do not use `clarify`; handle that request outside the clarify workflow from the start
305
305
 
306
306
  Preferred explicit handoff contract:
307
307
 
308
308
  - Recommended invocation: `$plan <slug>`
309
- - Artifact-pinned invocation when needed: `$plan --direct .loopx/specs/clarify-<slug>-<timestamp>.md`
309
+ - Artifact-pinned invocation when needed: `$plan --direct .loopx/intake/clarify-<slug>-<timestamp>.md`
310
310
  - Consumer behavior: treat the clarify spec as the source of truth for intent, non-goals, decision boundaries, constraints, and design direction; do not reopen clarification by default
311
311
 
312
- `clarify` itself does not implement the feature.
312
+ `clarify` itself does not implement the feature. The handoff recommendation must name `plan` as the next workflow step.
313
313
 
314
314
  </Process>
315
315
 
@@ -328,6 +328,7 @@ Preferred explicit handoff contract:
328
328
 
329
329
  <Must_Not_Decide_Automatically>
330
330
  - approval to move from clarify into plan
331
+ - skipping `plan` after producing a clarify spec
331
332
  - implementation details that were never clarified or grounded
332
333
  - widening the task because a broader redesign sounds cleaner
333
334
  </Must_Not_Decide_Automatically>
@@ -40,7 +40,7 @@ By default, `plan` includes the full consensus review loop formerly documented u
40
40
  Accepted inputs:
41
41
 
42
42
  - an approved loopx clarify workflow slug
43
- - `.loopx/specs/clarify-*.md`
43
+ - `.loopx/intake/clarify-*.md`
44
44
  - `.omx/specs/deep-interview-*.md`
45
45
  - a direct task description when enough context is already present
46
46
  - `--direct <spec-path>` to force a specific requirements artifact
@@ -174,7 +174,7 @@ The final plan must include:
174
174
 
175
175
  - ADR: Decision, Drivers, Alternatives considered, Why chosen, Consequences, Follow-ups
176
176
  - concrete implementation steps sized to the actual task
177
- - target long-lived spec domains and the requirements delta for archive
177
+ - target long-lived spec domains and an OpenSpec-style requirements delta for archive
178
178
  - vertical slices sized as independently verifiable tracer bullets, not horizontal layer-only task groups
179
179
  - execution inputs mapped to concrete sources before build starts
180
180
  - available execution lanes and recommended lane
@@ -220,7 +220,8 @@ The plan gate is blocked until:
220
220
 
221
221
  - plan package artifacts exist
222
222
  - change proposal, spec delta, design, tasks, vertical slices, and artifact graph exist
223
- - spec delta declares target domains and added or modified requirements
223
+ - spec delta declares target domains and `## ADDED|MODIFIED|REMOVED|RENAMED Requirements` blocks
224
+ - every ADDED or MODIFIED requirement uses `### Requirement:`, contains SHALL or MUST text, and includes at least one `#### Scenario:`
224
225
  - vertical slices contain at least one `AFK` or `HITL` end-to-end slice with acceptance criteria and verification signal
225
226
  - Architect review is complete
226
227
  - Critic verdict is `approve`
@@ -135,6 +135,7 @@ function buildIterationData({ slug, iteration, noDeslop = false }, scriptEntry =
135
135
  `deslop=${deslopStatus}`,
136
136
  `regression=${regressionStatus}`,
137
137
  ],
138
+ changedFiles: normalizeArray(scriptEntry.changedFiles),
138
139
  delegations: normalizeDelegations(scriptEntry.delegations),
139
140
  architectFindings: scriptEntry.architectFindings || (
140
141
  architectVerdict === 'approve'
@@ -222,6 +223,7 @@ function lanePrompt(context, laneName) {
222
223
  ' "summary": string,',
223
224
  ' "evidence": [{"id": string, "kind": string, "summary": string, "ref": string}],',
224
225
  ' "delegations": [{"id": string, "role": string, "status": "active" | "complete" | "failed" | "blocked" | "pending" | "skipped", "blocking": boolean, "scope": string[], "evidence_path": string | null, "summary": string}],',
226
+ ' "changedFiles": string[],',
225
227
  ' "executionEvidence": string[],',
226
228
  ' "verificationEvidence": string[],',
227
229
  ' "limitations": string[]',
@@ -262,6 +264,7 @@ function deslopPrompt(context, changedEvidence) {
262
264
  ' "status": "complete" | "failed" | "pending" | "skipped",',
263
265
  ' "summary": string,',
264
266
  ' "evidence": [{"id": string, "kind": string, "summary": string, "ref": string}],',
267
+ ' "changedFiles": string[],',
265
268
  ' "limitations": string[]',
266
269
  '}',
267
270
  '',
@@ -396,6 +399,11 @@ export function createRealBuildAdapter({ model, codexExecJson = runCodexExecJson
396
399
  ...normalizeEvidence(regressionReport.evidence).map((item) => `${item.kind}:${item.summary}:${item.ref}`),
397
400
  ],
398
401
  architectFindings: normalizeArray(architectReport.findings, ['Architect gate returned no findings.']),
402
+ changedFiles: normalizeArray([
403
+ ...normalizeArray(executionLane.changedFiles),
404
+ ...normalizeArray(evidenceLane.changedFiles),
405
+ ...normalizeArray(deslopReport.changedFiles),
406
+ ]),
399
407
  delegations: lanes.flatMap((lane) => normalizeDelegations(lane.delegations)),
400
408
  limitations: [
401
409
  ...normalizeArray(executionLane.limitations),
package/src/cli.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { archiveStage, autopilotStage, approveStage, buildStage, clarifyStage, initWorkspace, planStage, reviewStage, statusSummary } from './workflow.mjs';
4
+ import { renderHtmlViews } from './html-views.mjs';
4
5
  import { installBundledSkills } from './install-discovery.mjs';
5
6
  import { nextSkillCommand, withNextSkill } from './next-skill.mjs';
6
7
  import { doctorRuntime, migrateLegacyRuntime } from './runtime-maintenance.mjs';
@@ -18,6 +19,7 @@ function usage() {
18
19
  ' loopx review <slug> [--reviewer <name>]',
19
20
  ' loopx archive <slug>',
20
21
  ' loopx autopilot <slug> [--reviewer <name>]',
22
+ ' loopx render [slug|--all]',
21
23
  ' loopx status [slug] [--json]',
22
24
  ' loopx setup-context',
23
25
  ' loopx doctor',
@@ -202,6 +204,14 @@ async function main() {
202
204
  console.log(JSON.stringify({ ok: true, command, root: result.root, state: result.state, runPath: result.runPath }, null, 2));
203
205
  return;
204
206
  }
207
+ case 'render': {
208
+ const result = await renderHtmlViews(process.cwd(), {
209
+ slug: positionals[0],
210
+ all: Boolean(options.get('--all')),
211
+ });
212
+ console.log(JSON.stringify({ ok: true, command, ...result }, null, 2));
213
+ return;
214
+ }
205
215
  case 'status': {
206
216
  const result = await statusSummary(process.cwd(), positionals[0]);
207
217
  if (options.get('--json')) {
@@ -8,7 +8,7 @@ export const CONTEXT_MANIFEST_SCHEMA_VERSION = 1;
8
8
  const MAX_MANIFEST_ROWS = 80;
9
9
 
10
10
  function normalizePath(cwd, path) {
11
- const resolved = resolve(path);
11
+ const resolved = resolve(cwd, path);
12
12
  const rel = relative(cwd, resolved);
13
13
  return rel && !rel.startsWith('..') ? rel : resolved;
14
14
  }
@@ -0,0 +1,316 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { basename, join, relative } from 'node:path';
4
+
5
+ import { nextSkillCommand } from './next-skill.mjs';
6
+ import { statusSummary } from './workflow.mjs';
7
+
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: 'execution-record.md', label: '执行记录', page: 'build.html' },
15
+ { name: 'review-report.md', label: '评审报告', page: 'review.html' },
16
+ ];
17
+
18
+ const PAGE_GROUPS = [
19
+ { file: 'intake.html', title: '需求澄清', artifacts: ['spec.md'] },
20
+ { file: 'plan.html', title: '计划与架构', artifacts: ['plan.md', 'architecture.md', 'development-plan.md', 'test-plan.md'] },
21
+ { file: 'build.html', title: '执行与验证', artifacts: ['execution-record.md'] },
22
+ { file: 'review.html', title: '评审结论', artifacts: ['review-report.md'] },
23
+ ];
24
+
25
+ function escapeHtml(value) {
26
+ return String(value ?? '')
27
+ .replaceAll('&', '&amp;')
28
+ .replaceAll('<', '&lt;')
29
+ .replaceAll('>', '&gt;')
30
+ .replaceAll('"', '&quot;')
31
+ .replaceAll("'", '&#39;');
32
+ }
33
+
34
+ function htmlDoc({ title, body }) {
35
+ return [
36
+ '<!doctype html>',
37
+ '<html lang="zh-CN">',
38
+ '<head>',
39
+ ' <meta charset="utf-8">',
40
+ ' <meta name="viewport" content="width=device-width, initial-scale=1">',
41
+ ` <title>${escapeHtml(title)}</title>`,
42
+ ' <style>',
43
+ ' :root { color-scheme: light; --text: #17202a; --muted: #5f6f7f; --line: #d8e0e8; --bg: #f6f8fa; --panel: #ffffff; --accent: #1769aa; }',
44
+ ' * { box-sizing: border-box; }',
45
+ ' body { margin: 0; font: 15px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--text); background: var(--bg); }',
46
+ ' main { max-width: 1080px; margin: 0 auto; padding: 28px 20px 48px; }',
47
+ ' header { margin-bottom: 20px; }',
48
+ ' h1 { margin: 0 0 8px; font-size: 28px; line-height: 1.25; }',
49
+ ' h2 { margin: 24px 0 10px; font-size: 18px; }',
50
+ ' h3 { margin: 18px 0 8px; font-size: 16px; }',
51
+ ' a { color: var(--accent); text-decoration: none; }',
52
+ ' a:hover { text-decoration: underline; }',
53
+ ' .muted { color: var(--muted); }',
54
+ ' .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }',
55
+ ' .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 14px 16px; }',
56
+ ' .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; }',
57
+ ' table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); }',
58
+ ' th, td { padding: 8px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }',
59
+ ' th { color: var(--muted); font-weight: 600; }',
60
+ ' pre { overflow: auto; padding: 12px; background: #0f1720; color: #edf4fb; border-radius: 8px; }',
61
+ ' code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }',
62
+ ' .markdown { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 16px; }',
63
+ ' </style>',
64
+ '</head>',
65
+ '<body>',
66
+ '<main>',
67
+ body,
68
+ '</main>',
69
+ '</body>',
70
+ '</html>',
71
+ ].join('\n');
72
+ }
73
+
74
+ function listItems(items) {
75
+ const values = Array.isArray(items) ? items.filter(Boolean) : [];
76
+ if (values.length === 0) {
77
+ return '<span class="muted">无</span>';
78
+ }
79
+ return values.map((item) => `<span class="badge">${escapeHtml(item)}</span>`).join(' ');
80
+ }
81
+
82
+ function markdownToHtml(markdown) {
83
+ const lines = String(markdown || '').split('\n');
84
+ const html = [];
85
+ let inCode = false;
86
+ let listOpen = false;
87
+
88
+ const closeList = () => {
89
+ if (listOpen) {
90
+ html.push('</ul>');
91
+ listOpen = false;
92
+ }
93
+ };
94
+
95
+ for (const line of lines) {
96
+ if (line.startsWith('```')) {
97
+ if (inCode) {
98
+ html.push('</code></pre>');
99
+ inCode = false;
100
+ } else {
101
+ closeList();
102
+ html.push('<pre><code>');
103
+ inCode = true;
104
+ }
105
+ continue;
106
+ }
107
+ if (inCode) {
108
+ html.push(escapeHtml(line));
109
+ continue;
110
+ }
111
+ if (/^#{1,4}\s+/.test(line)) {
112
+ closeList();
113
+ const level = Math.min(4, line.match(/^#+/)?.[0].length || 2);
114
+ html.push(`<h${level}>${escapeHtml(line.replace(/^#{1,4}\s+/, ''))}</h${level}>`);
115
+ continue;
116
+ }
117
+ if (line.startsWith('- ')) {
118
+ if (!listOpen) {
119
+ html.push('<ul>');
120
+ listOpen = true;
121
+ }
122
+ html.push(`<li>${escapeHtml(line.slice(2))}</li>`);
123
+ continue;
124
+ }
125
+ if (!line.trim()) {
126
+ closeList();
127
+ continue;
128
+ }
129
+ closeList();
130
+ html.push(`<p>${escapeHtml(line)}</p>`);
131
+ }
132
+ closeList();
133
+ if (inCode) {
134
+ html.push('</code></pre>');
135
+ }
136
+ return html.join('\n');
137
+ }
138
+
139
+ function artifactLink(viewRoot, artifactPath, label) {
140
+ const href = relative(viewRoot, artifactPath).replaceAll('\\', '/');
141
+ return `<a href="${escapeHtml(href)}">${escapeHtml(label)}</a>`;
142
+ }
143
+
144
+ function statusPanels(status) {
145
+ const state = status.state || {};
146
+ const readiness = state.readiness || {};
147
+ const authorization = state.authorization || {};
148
+ const nextSkill = nextSkillCommand(state);
149
+ return [
150
+ '<section class="grid">',
151
+ `<div class="panel"><strong>阶段</strong><br>${escapeHtml(state.current_stage || '(none)')}</div>`,
152
+ `<div class="panel"><strong>状态</strong><br>${escapeHtml(state.stage_status || '(unknown)')}</div>`,
153
+ `<div class="panel"><strong>下一步</strong><br><code>${escapeHtml(nextSkill || status.next_action || 'none')}</code></div>`,
154
+ `<div class="panel"><strong>归档</strong><br>${escapeHtml(state.archive_status || 'pending')}</div>`,
155
+ '</section>',
156
+ '<section class="panel">',
157
+ '<h2>readiness / authorization</h2>',
158
+ '<table><thead><tr><th>关卡</th><th>ready</th><th>authorized</th><th>blockers</th></tr></thead><tbody>',
159
+ ...['plan', 'build', 'review', 'done', 'archive'].map((key) => [
160
+ '<tr>',
161
+ `<td>${escapeHtml(key)}</td>`,
162
+ `<td>${escapeHtml(readiness[key]?.ready ?? false)}</td>`,
163
+ `<td>${escapeHtml(authorization[key]?.authorized ?? false)}</td>`,
164
+ `<td>${listItems(readiness[key]?.blockers || [])}</td>`,
165
+ '</tr>',
166
+ ].join('')),
167
+ '</tbody></table>',
168
+ '</section>',
169
+ ].join('\n');
170
+ }
171
+
172
+ async function renderWorkflowPages(status) {
173
+ const root = status.root;
174
+ const viewRoot = join(root, 'view');
175
+ await mkdir(viewRoot, { recursive: true });
176
+
177
+ const artifactRows = WORKFLOW_ARTIFACTS.map((artifact) => {
178
+ const artifactPath = join(root, artifact.name);
179
+ return {
180
+ ...artifact,
181
+ path: artifactPath,
182
+ exists: existsSync(artifactPath),
183
+ };
184
+ });
185
+
186
+ for (const group of PAGE_GROUPS) {
187
+ const sections = [];
188
+ for (const artifactName of group.artifacts) {
189
+ const artifact = artifactRows.find((item) => item.name === artifactName);
190
+ if (!artifact?.exists) {
191
+ continue;
192
+ }
193
+ const text = await readFile(artifact.path, 'utf8');
194
+ sections.push([
195
+ `<section class="markdown">`,
196
+ `<p class="muted">${artifactLink(viewRoot, artifact.path, artifact.name)}</p>`,
197
+ markdownToHtml(text),
198
+ '</section>',
199
+ ].join('\n'));
200
+ }
201
+ await writeFile(join(viewRoot, group.file), htmlDoc({
202
+ title: `${group.title} - ${status.slug}`,
203
+ body: [
204
+ '<header>',
205
+ `<h1>${escapeHtml(group.title)}</h1>`,
206
+ `<p class="muted">工作流:${escapeHtml(status.slug)}</p>`,
207
+ '<p><a href="index.html">返回工作流首页</a></p>',
208
+ '</header>',
209
+ sections.length > 0 ? sections.join('\n') : '<section class="panel muted">暂无对应产物。</section>',
210
+ ].join('\n'),
211
+ }));
212
+ }
213
+
214
+ const indexBody = [
215
+ '<header>',
216
+ `<h1>工作流 ${escapeHtml(status.slug)}</h1>`,
217
+ `<p class="muted">HTML 是派生阅读视图;Markdown 和 JSON 仍是运行时事实源。</p>`,
218
+ '</header>',
219
+ statusPanels(status),
220
+ '<section class="panel">',
221
+ '<h2>关键产物</h2>',
222
+ '<table><thead><tr><th>产物</th><th>状态</th><th>阅读视图</th><th>原始文件</th></tr></thead><tbody>',
223
+ ...artifactRows.map((artifact) => [
224
+ '<tr>',
225
+ `<td>${escapeHtml(artifact.label)}</td>`,
226
+ `<td>${artifact.exists ? '存在' : '缺失'}</td>`,
227
+ `<td>${artifact.exists ? `<a href="${escapeHtml(artifact.page)}">${escapeHtml(basename(artifact.page))}</a>` : '<span class="muted">无</span>'}</td>`,
228
+ `<td>${artifact.exists ? artifactLink(viewRoot, artifact.path, artifact.name) : '<span class="muted">无</span>'}</td>`,
229
+ '</tr>',
230
+ ].join('')),
231
+ '</tbody></table>',
232
+ '</section>',
233
+ ].join('\n');
234
+
235
+ const workflowViewPath = join(viewRoot, 'index.html');
236
+ await writeFile(workflowViewPath, htmlDoc({
237
+ title: `loopx 工作流 ${status.slug}`,
238
+ body: indexBody,
239
+ }));
240
+
241
+ return workflowViewPath;
242
+ }
243
+
244
+ async function renderWorkspaceIndex(workspaceStatus, renderedSlugs = []) {
245
+ const viewsRoot = join(workspaceStatus.workspaceRoot, 'views');
246
+ await mkdir(viewsRoot, { recursive: true });
247
+ const rendered = new Set(renderedSlugs);
248
+ const rows = (workspaceStatus.workflows || []).map((workflow) => {
249
+ const href = `../workflows/${workflow.slug}/view/index.html`;
250
+ const link = rendered.has(workflow.slug)
251
+ ? `<a href="${escapeHtml(href)}">${escapeHtml(workflow.slug)}</a>`
252
+ : escapeHtml(workflow.slug);
253
+ return [
254
+ '<tr>',
255
+ `<td>${link}</td>`,
256
+ `<td>${escapeHtml(workflow.current_stage || '(none)')}</td>`,
257
+ `<td>${escapeHtml(workflow.contract)}</td>`,
258
+ `<td>${escapeHtml(workflow.missing_artifact_count)}</td>`,
259
+ '</tr>',
260
+ ].join('');
261
+ });
262
+ const workspaceViewPath = join(viewsRoot, 'index.html');
263
+ await writeFile(workspaceViewPath, htmlDoc({
264
+ title: 'loopx 工作台',
265
+ body: [
266
+ '<header>',
267
+ '<h1>loopx 工作台</h1>',
268
+ `<p class="muted">工作区:${escapeHtml(workspaceStatus.workspaceRoot)}</p>`,
269
+ '</header>',
270
+ '<section class="panel">',
271
+ '<h2>工作流</h2>',
272
+ '<table><thead><tr><th>工作流</th><th>阶段</th><th>契约</th><th>缺失产物数</th></tr></thead><tbody>',
273
+ rows.join('\n') || '<tr><td colspan="4" class="muted">暂无工作流。</td></tr>',
274
+ '</tbody></table>',
275
+ '</section>',
276
+ ].join('\n'),
277
+ }));
278
+ return workspaceViewPath;
279
+ }
280
+
281
+ export async function renderHtmlViews(cwd, { slug = null, all = false } = {}) {
282
+ const workspaceStatus = await statusSummary(cwd);
283
+ if (!workspaceStatus.initialized) {
284
+ throw new Error('loopx_workspace_not_initialized');
285
+ }
286
+
287
+ const workflowViews = [];
288
+ if (all || !slug) {
289
+ for (const workflow of workspaceStatus.workflows) {
290
+ if (workflow.legacy) {
291
+ continue;
292
+ }
293
+ const workflowStatus = await statusSummary(cwd, workflow.slug);
294
+ workflowViews.push({
295
+ slug: workflow.slug,
296
+ path: await renderWorkflowPages(workflowStatus),
297
+ });
298
+ }
299
+ } else if (slug) {
300
+ const workflowStatus = await statusSummary(cwd, slug);
301
+ if (!workflowStatus.state || workflowStatus.legacy) {
302
+ throw new Error('render_workflow_not_available');
303
+ }
304
+ workflowViews.push({
305
+ slug: workflowStatus.slug,
306
+ path: await renderWorkflowPages(workflowStatus),
307
+ });
308
+ }
309
+
310
+ const workspaceViewPath = await renderWorkspaceIndex(workspaceStatus, workflowViews.map((item) => item.slug));
311
+ return {
312
+ workflowViews,
313
+ workflowViewPath: workflowViews[0]?.path || null,
314
+ workspaceViewPath,
315
+ };
316
+ }