@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.
- package/README.md +84 -6
- package/README.zh-CN.md +103 -10
- package/assets/logo.svg +89 -0
- package/package.json +2 -1
- package/plugins/loopx/scripts/plugin-install.test.mjs +13 -0
- package/plugins/loopx/skills/archive/SKILL.md +10 -0
- package/plugins/loopx/skills/clarify/SKILL.md +9 -8
- package/plugins/loopx/skills/plan/SKILL.md +4 -3
- package/scripts/codex-workflow-hook.mjs +101 -6
- package/skills/archive/SKILL.md +10 -0
- package/skills/clarify/SKILL.md +9 -8
- package/skills/plan/SKILL.md +4 -3
- package/src/build-runtime.mjs +8 -0
- package/src/cli.mjs +10 -0
- package/src/context-manifest.mjs +1 -1
- package/src/html-views.mjs +316 -0
- package/src/plan-runtime.mjs +23 -0
- package/src/review-runtime.mjs +203 -23
- package/src/runtime-maintenance.mjs +1 -0
- package/src/workflow.mjs +491 -94
|
@@ -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/
|
|
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
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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: ${
|
|
135
|
-
`next: ${
|
|
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)),
|
package/skills/archive/SKILL.md
CHANGED
|
@@ -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.
|
package/skills/clarify/SKILL.md
CHANGED
|
@@ -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`,
|
|
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`
|
|
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/
|
|
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`
|
|
303
|
-
-
|
|
304
|
-
-
|
|
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/
|
|
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>
|
package/skills/plan/SKILL.md
CHANGED
|
@@ -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/
|
|
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
|
|
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
|
|
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`
|
package/src/build-runtime.mjs
CHANGED
|
@@ -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')) {
|
package/src/context-manifest.mjs
CHANGED
|
@@ -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('&', '&')
|
|
28
|
+
.replaceAll('<', '<')
|
|
29
|
+
.replaceAll('>', '>')
|
|
30
|
+
.replaceAll('"', '"')
|
|
31
|
+
.replaceAll("'", ''');
|
|
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
|
+
}
|