paseo-req-kit 1.9.0-beta.1 → 1.9.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +2 -0
- package/bin/req-plugin.mjs +226 -0
- package/bin/req.mjs +16 -0
- package/contracts/cli-json.md +2 -1
- package/contracts/feature-files.md +10 -0
- package/contracts/state.md +1 -0
- package/docs/adr/0027-paseo-plugin.md +23 -0
- package/docs/adr/0028-external-integrations.md +22 -0
- package/docs/adr/README.md +2 -0
- package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +2 -0
- package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +25 -0
- package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +3 -1
- package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +32 -0
- package/engine/src/api.ts +2 -1
- package/engine/src/cli-args.ts +4 -1
- package/engine/src/config.ts +21 -1
- package/engine/src/control.ts +2 -0
- package/engine/src/integration-cmd.ts +161 -0
- package/engine/src/integrations.ts +400 -0
- package/engine/src/launch.ts +12 -0
- package/engine/src/pipeline.ts +31 -1
- package/engine/src/preflight.ts +2 -0
- package/engine/src/state.ts +3 -0
- package/install.mjs +3 -0
- package/package.json +2 -1
- package/paseo-plugin/OVERVIEW.md +7 -0
- package/paseo-plugin/client/data.ts +55 -0
- package/paseo-plugin/client/detail.tsx +303 -0
- package/paseo-plugin/client/format.ts +111 -0
- package/paseo-plugin/client/live.ts +161 -0
- package/paseo-plugin/client/markdown.tsx +219 -0
- package/paseo-plugin/client/overview.tsx +89 -0
- package/paseo-plugin/client/panel.tsx +56 -0
- package/paseo-plugin/client/row.tsx +40 -0
- package/paseo-plugin/client/settings.tsx +90 -0
- package/paseo-plugin/client/sidebar.tsx +27 -0
- package/paseo-plugin/client/stats.tsx +80 -0
- package/paseo-plugin/client/ui.tsx +111 -0
- package/paseo-plugin/index.client.tsx +52 -0
- package/paseo-plugin/index.server.ts +41 -0
- package/paseo-plugin/package.json +22 -0
- package/paseo-plugin/paseo-plugin.json +6 -0
- package/paseo-plugin/server/handlers.ts +45 -0
- package/paseo-plugin/server/req.ts +100 -0
- package/paseo-plugin/shared/contracts.ts +167 -0
- package/paseo-plugin/tsconfig.json +16 -0
- package/skills/req/SKILL.md +1 -0
- package/skills/req/playbooks/new.md +3 -1
package/engine/src/pipeline.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
import { kitVersion } from "./locations.js";
|
|
34
34
|
import { type AgentUsage, describeTotals, totals } from "./usage.js";
|
|
35
35
|
import { dispatchNotifiers, type NotifierEventType } from "./notifiers.js";
|
|
36
|
+
import { describeExternal, type IntegrationEvent, type Runner, syncIntegration } from "./integrations.js";
|
|
36
37
|
import { principlesSection, projectAdditions, type PromptLocations, readToolPrompt } from "./prompts.js";
|
|
37
38
|
import type { AgentRuntime, ManagedService, ServiceTarget, StepAgent } from "./runtime.js";
|
|
38
39
|
import { type CheckOutcome, type GateRun, type JointRepo, type PhaseName, type PipelineState, type RunPhases, type StepRecord, writeState } from "./state.js";
|
|
@@ -56,6 +57,8 @@ export interface PipelineContext {
|
|
|
56
57
|
approveCommand?: string;
|
|
57
58
|
log: (message: string) => void;
|
|
58
59
|
sleep: (ms: number) => Promise<void>;
|
|
60
|
+
/** Runs integration commands; tests replace it. Default: the real command line. */
|
|
61
|
+
integrationRunner?: Runner;
|
|
59
62
|
/** Paseo-managed services of this run (filled in by the pipeline). */
|
|
60
63
|
services?: { names: string[] | null | undefined; started: Set<string> };
|
|
61
64
|
/** Paseo tools the step agents get; looked up once (undefined: not yet). */
|
|
@@ -384,6 +387,21 @@ async function notifyExternal(ctx: PipelineContext, type: NotifierEventType, tex
|
|
|
384
387
|
);
|
|
385
388
|
}
|
|
386
389
|
|
|
390
|
+
/** Keeps the requirement's card in each configured external system in step (1.9). Never throws. */
|
|
391
|
+
async function integrate(ctx: PipelineContext, event: IntegrationEvent): Promise<void> {
|
|
392
|
+
if (ctx.config.integrations.length === 0) return;
|
|
393
|
+
for (const config of ctx.config.integrations) {
|
|
394
|
+
await syncIntegration({
|
|
395
|
+
config,
|
|
396
|
+
ctx: { repo: ctx.repo, featureDir: ctx.featureDir, state: ctx.state, projectDefaults: ctx.config.integrationDefaults[config.name] },
|
|
397
|
+
event,
|
|
398
|
+
run: ctx.integrationRunner,
|
|
399
|
+
log: ctx.log,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
save(ctx);
|
|
403
|
+
}
|
|
404
|
+
|
|
387
405
|
/** Sends a progress note once per run attempt, when enabled. */
|
|
388
406
|
async function milestone(ctx: PipelineContext, id: string, text: string): Promise<void> {
|
|
389
407
|
if (!ctx.config.notifyMilestones || ctx.state.milestones.includes(id)) return;
|
|
@@ -1648,8 +1666,13 @@ async function setUpJoint(ctx: PipelineContext): Promise<void> {
|
|
|
1648
1666
|
}
|
|
1649
1667
|
|
|
1650
1668
|
/** What the report is told about this attempt: usage, the quality gate, the scope check. */
|
|
1669
|
+
/** The requirement's cards in external systems, for the report's appendix. */
|
|
1670
|
+
function externalLines(ctx: PipelineContext): string[] {
|
|
1671
|
+
return describeExternal(ctx.state.external).map((l) => `外部系统 ${l}`);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1651
1674
|
function reportLines(ctx: PipelineContext): string[] {
|
|
1652
|
-
const lines = [...usageLines(ctx), ...gateLines(ctx), ...scopeLines(ctx), ...acceptanceLines(ctx), ...jointLines(ctx)];
|
|
1675
|
+
const lines = [...usageLines(ctx), ...gateLines(ctx), ...scopeLines(ctx), ...acceptanceLines(ctx), ...jointLines(ctx), ...externalLines(ctx)];
|
|
1653
1676
|
if (ctx.config.checks.length === 0 && ctx.state.type !== "assessment") {
|
|
1654
1677
|
lines.push("质量门:项目没有配置检查(checks),流水线没有自己运行检查,以各步骤记录的检查结果为准");
|
|
1655
1678
|
}
|
|
@@ -1693,6 +1716,7 @@ async function flow(ctx: PipelineContext): Promise<Record<string, unknown>> {
|
|
|
1693
1716
|
}
|
|
1694
1717
|
ctx.state.phase = "verify";
|
|
1695
1718
|
save(ctx);
|
|
1719
|
+
await integrate(ctx, "verify");
|
|
1696
1720
|
}
|
|
1697
1721
|
if (!(await verifyPhase(ctx, design))) break;
|
|
1698
1722
|
await handBack(ctx);
|
|
@@ -1705,6 +1729,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1705
1729
|
ctx.state.status = "running";
|
|
1706
1730
|
ctx.state.stopReason = null;
|
|
1707
1731
|
save(ctx);
|
|
1732
|
+
await integrate(ctx, phaseOf(ctx) === "verify" ? "verify" : "started");
|
|
1708
1733
|
try {
|
|
1709
1734
|
await checkQuota(ctx);
|
|
1710
1735
|
const report = await flow(ctx);
|
|
@@ -1730,6 +1755,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1730
1755
|
const text = developOnly
|
|
1731
1756
|
? `研发已完成(只做研发):${outcomeText[report.outcome as string] ?? report.outcome},请按提测说明人工测试。`
|
|
1732
1757
|
: `开发流水线已结束:${outcomeText[report.outcome as string] ?? report.outcome}。`;
|
|
1758
|
+
await integrate(ctx, "completed");
|
|
1733
1759
|
await notifyExternal(ctx, "completed", text, {
|
|
1734
1760
|
outcome: report.outcome,
|
|
1735
1761
|
summary: report.summary,
|
|
@@ -1757,6 +1783,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1757
1783
|
handbacks: ctx.state.handbacks ?? 0,
|
|
1758
1784
|
}),
|
|
1759
1785
|
);
|
|
1786
|
+
await integrate(ctx, "phase_done");
|
|
1760
1787
|
await notifyExternal(ctx, "phase_done", `研发已完成,可以提测:提测说明在 ${ctx.featureDir}/handoff.md。${verifyAt ? `已预约在 ${verifyAt} 开始测试验证。` : ""}`, {
|
|
1761
1788
|
handoffPath: `${ctx.featureDir}/handoff.md`,
|
|
1762
1789
|
mode: runPhases(ctx.state).mode,
|
|
@@ -1785,6 +1812,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1785
1812
|
autoContinueAt,
|
|
1786
1813
|
}),
|
|
1787
1814
|
);
|
|
1815
|
+
await integrate(ctx, "approval");
|
|
1788
1816
|
await notifyExternal(ctx, "approval", `方案已通过审核,请需求方确认"做完后会看到什么"再开始写代码:${ctx.featureDir}/preview.md`, {
|
|
1789
1817
|
previewPath: `${ctx.featureDir}/preview.md`,
|
|
1790
1818
|
approveCommand: approveCommandOf(ctx),
|
|
@@ -1805,6 +1833,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1805
1833
|
const external = reproduce
|
|
1806
1834
|
? `没能复现这个缺陷,需要需求方补充 ${error.questions.length} 项信息,流水线已暂停,请到发起的对话中回答。`
|
|
1807
1835
|
: `方案审核发现 ${error.questions.length} 个需要需求方决定的问题,流水线已暂停,请到发起的对话中回答。`;
|
|
1836
|
+
await integrate(ctx, "blocked");
|
|
1808
1837
|
await notifyExternal(ctx, "blocked", external, {
|
|
1809
1838
|
questions: error.questions,
|
|
1810
1839
|
resumeCommand: ctx.resumeCommand,
|
|
@@ -1827,6 +1856,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1827
1856
|
autoResumeAt,
|
|
1828
1857
|
}),
|
|
1829
1858
|
);
|
|
1859
|
+
await integrate(ctx, "failed");
|
|
1830
1860
|
await notifyExternal(ctx, "failed", `开发流水线在「${stepTitle}」步骤中断:${(error as Error).message}`, {
|
|
1831
1861
|
stepTitle,
|
|
1832
1862
|
reason: (error as Error).message,
|
package/engine/src/preflight.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface LaunchArgs {
|
|
|
29
29
|
stop: boolean;
|
|
30
30
|
/** --list: print every requirement in this repository's worktrees. */
|
|
31
31
|
list: boolean;
|
|
32
|
+
/** --card-fields: what /req confirms with the spec for the external systems (card.json). */
|
|
33
|
+
cardFields?: boolean;
|
|
32
34
|
/** --spec-review: independent review of the draft spec, before confirmation. */
|
|
33
35
|
specReview: boolean;
|
|
34
36
|
/** --worktree: create a Paseo worktree workspace for this requirement and develop there. */
|
package/engine/src/state.ts
CHANGED
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import type { DefectHandling, PhaseMode, ProfileName, RequirementType, VerifyEnvironment } from "./config.js";
|
|
6
|
+
import type { ExternalRecord } from "./integrations.js";
|
|
6
7
|
import { registerRequirement } from "./records.js";
|
|
7
8
|
import type { AgentUsage } from "./usage.js";
|
|
8
9
|
import type { StepId } from "./steps.js";
|
|
@@ -106,6 +107,8 @@ export interface PipelineState {
|
|
|
106
107
|
approval?: { attempt: number; status: "waiting" | "approved"; at: string; auto?: boolean } | null;
|
|
107
108
|
/** Quality gate: the checks' results before the change, and every run after a step (1.8). */
|
|
108
109
|
gate?: { baseline: GateBaseline | null; runs: GateRun[] } | null;
|
|
110
|
+
/** External systems (1.9): the requirement's card in each, by integration name. */
|
|
111
|
+
external?: Record<string, ExternalRecord> | null;
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
export interface JointRepo {
|
package/install.mjs
CHANGED
|
@@ -911,6 +911,9 @@ if (values.check) {
|
|
|
911
911
|
if (active && path.resolve(active.root) !== path.resolve(KIT_ROOT)) ok(`已从 ${active.version} 切换到 ${VERSION}`);
|
|
912
912
|
else ok(`已启用 ${VERSION}`);
|
|
913
913
|
writeShims();
|
|
914
|
+
// The Paseo plugin, when installed, follows the active version (bin/req-plugin.mjs).
|
|
915
|
+
const { refreshPlugin } = await import("./bin/req-plugin.mjs");
|
|
916
|
+
refreshPlugin({ kitRoot: KIT_ROOT, log: (message) => ok(message) });
|
|
914
917
|
}
|
|
915
918
|
checkLegacyLeftovers(roots);
|
|
916
919
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paseo-req-kit",
|
|
3
|
-
"version": "1.9.0-beta.
|
|
3
|
+
"version": "1.9.0-beta.3",
|
|
4
4
|
"description": "req-kit: a requirement-development workflow for Paseo. /req aligns a requirement in chat; the engine runs design, implementation, review and verification as Paseo agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"bin/",
|
|
50
50
|
"engine/src/",
|
|
51
51
|
"engine/prompts/",
|
|
52
|
+
"paseo-plugin/",
|
|
52
53
|
"skills/",
|
|
53
54
|
"principles/",
|
|
54
55
|
"contracts/",
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
req-kit 的需求在 Paseo 里的样子:本机所有项目的需求一页看完,哪些在等你确认、哪些卡住了、哪些在运行;点开一个需求,看它走到哪一步、每一步用了多久、质量门的结果,读需求说明、方案、确认预览、提测说明和完成报告。
|
|
2
|
+
|
|
3
|
+
需要人处理的时候,可以直接在这里确认方案、停止、继续或开始测试验证。这些操作和在终端运行 `req approve`、`req stop`、`req resume`、`req verify` 完全一样,流水线本身不受插件影响;不想让人在网页上操作时,在插件设置中打开"只读"。
|
|
4
|
+
|
|
5
|
+
还有:侧边栏的"需求"(带待处理数)、每个工作区头部的"需求"按钮和面板、步骤 agent 输入框旁的"需求"按钮,以及使用统计(交付周期、返工、等待、用量)。
|
|
6
|
+
|
|
7
|
+
需要这台 daemon 上安装 req-kit 1.9 或更新的版本,插件通过本机的 `req api` 读取需求;它不连接任何外部服务。用 `req plugin install` 安装和更新。
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Data hooks: every read is an RPC to the daemon side (req api), cached by React Query.
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useRpc, useSettings } from "@getpaseo/plugin/client";
|
|
4
|
+
import { fileRpc, listRpc, preferences, runRpc, statsRpc, statusRpc, versionRpc, type Action } from "../shared/contracts";
|
|
5
|
+
|
|
6
|
+
export function usePreferences(): { readOnly: boolean; refreshMs: number } {
|
|
7
|
+
const settings = useSettings(preferences);
|
|
8
|
+
if (settings.status !== "ready") return { readOnly: true, refreshMs: 10_000 };
|
|
9
|
+
return { readOnly: settings.values.readOnly, refreshMs: settings.values.refreshSeconds * 1000 };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useRequirements() {
|
|
13
|
+
const list = useRpc(listRpc);
|
|
14
|
+
const { refreshMs } = usePreferences();
|
|
15
|
+
return useQuery({ queryKey: ["req", "list"], queryFn: () => list({}), refetchInterval: refreshMs });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useRequirement(repo: string, featureDir: string) {
|
|
19
|
+
const status = useRpc(statusRpc);
|
|
20
|
+
const { refreshMs } = usePreferences();
|
|
21
|
+
return useQuery({
|
|
22
|
+
queryKey: ["req", "status", repo, featureDir],
|
|
23
|
+
queryFn: () => status({ repo, featureDir }),
|
|
24
|
+
refetchInterval: refreshMs,
|
|
25
|
+
enabled: Boolean(repo && featureDir),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useDocument(repo: string, featureDir: string, file: string | null) {
|
|
30
|
+
const read = useRpc(fileRpc);
|
|
31
|
+
return useQuery({
|
|
32
|
+
queryKey: ["req", "file", repo, featureDir, file],
|
|
33
|
+
queryFn: () => read({ repo, featureDir, file: file ?? "" }),
|
|
34
|
+
enabled: Boolean(file),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useStats(period: { since?: string; month?: string }) {
|
|
39
|
+
const stats = useRpc(statsRpc);
|
|
40
|
+
return useQuery({ queryKey: ["req", "stats", period.since ?? "", period.month ?? ""], queryFn: () => stats(period) });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useKitInfo() {
|
|
44
|
+
const version = useRpc(versionRpc);
|
|
45
|
+
return useQuery({ queryKey: ["req", "version"], queryFn: () => version({}), retry: false });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useAction() {
|
|
49
|
+
const run = useRpc(runRpc);
|
|
50
|
+
const queries = useQueryClient();
|
|
51
|
+
return useMutation({
|
|
52
|
+
mutationFn: (input: { action: Action; repo: string; featureDir: string; verifyEnv?: "local" | "deployed" }) => run(input),
|
|
53
|
+
onSettled: () => queries.invalidateQueries({ queryKey: ["req"] }),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// One requirement: where it stands, its steps, the quality gate, its documents, and the
|
|
2
|
+
// management actions (each one is `req <command>` on the daemon machine, after confirming).
|
|
3
|
+
import type { PluginHostProps, PluginScreenProps, PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
4
|
+
import { Modal, ScrollView, useToast } from "@getpaseo/plugin/client/react-native";
|
|
5
|
+
import { ExternalLink } from "@getpaseo/plugin/client/ui";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { Pressable, Text, View } from "react-native";
|
|
8
|
+
import type { Action, RequirementDetail, Step } from "../shared/contracts";
|
|
9
|
+
import { useAction, useDocument, usePreferences, useRequirement, useRequirements } from "./data";
|
|
10
|
+
import { ACTION_TEXT, actionsFor, ago, ATTENTION_LABELS, facts, FILE_LABELS, minutes, tokens } from "./format";
|
|
11
|
+
import { Markdown, tsvToMarkdown } from "./markdown";
|
|
12
|
+
import { Badge, Button, Chip, Status, useStyles } from "./ui";
|
|
13
|
+
|
|
14
|
+
type Theme = PluginHostProps["theme"];
|
|
15
|
+
type Layout = PluginHostProps["layout"];
|
|
16
|
+
type Navigation = PluginSurfaceProps["navigation"];
|
|
17
|
+
|
|
18
|
+
const OUTCOME_LABELS: Record<Step["outcome"], string> = { running: "运行中", completed: "完成", failed: "失败" };
|
|
19
|
+
|
|
20
|
+
function StepRow({ theme, layout, step, navigation }: { theme: Theme; layout: Layout; step: Step; navigation: Navigation }) {
|
|
21
|
+
const s = useStyles(theme, layout.compact);
|
|
22
|
+
const tone = step.outcome === "failed" ? "danger" : step.outcome === "running" ? "accent" : "muted";
|
|
23
|
+
const body = (
|
|
24
|
+
<View style={{ gap: 2, paddingVertical: 6 }}>
|
|
25
|
+
<View style={s.row}>
|
|
26
|
+
<Text style={[s.text, { flexShrink: 1 }]}>{step.title}</Text>
|
|
27
|
+
<Badge theme={theme} tone={tone}>
|
|
28
|
+
{step.outcome === "completed" && step.result ? step.result : OUTCOME_LABELS[step.outcome]}
|
|
29
|
+
</Badge>
|
|
30
|
+
</View>
|
|
31
|
+
<Text style={s.muted}>
|
|
32
|
+
第 {step.attempt} 次尝试 · {minutes(step.minutes)} · {ago(step.startedAt)}开始
|
|
33
|
+
</Text>
|
|
34
|
+
{step.error ? <Text style={s.danger}>{step.error}</Text> : null}
|
|
35
|
+
</View>
|
|
36
|
+
);
|
|
37
|
+
// A step agent opens in Paseo, where its whole conversation is.
|
|
38
|
+
if (!navigation || !step.agentId) return body;
|
|
39
|
+
return (
|
|
40
|
+
<Pressable accessibilityRole="button" accessibilityLabel={`打开 ${step.title} 的 agent`} onPress={() => navigation.openAgent({ agentId: step.agentId })}>
|
|
41
|
+
{body}
|
|
42
|
+
</Pressable>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function DocumentModal({
|
|
47
|
+
theme,
|
|
48
|
+
layout,
|
|
49
|
+
repo,
|
|
50
|
+
featureDir,
|
|
51
|
+
file,
|
|
52
|
+
onClose,
|
|
53
|
+
}: {
|
|
54
|
+
theme: Theme;
|
|
55
|
+
layout: Layout;
|
|
56
|
+
repo: string;
|
|
57
|
+
featureDir: string;
|
|
58
|
+
file: string | null;
|
|
59
|
+
onClose(): void;
|
|
60
|
+
}) {
|
|
61
|
+
const s = useStyles(theme, layout.compact);
|
|
62
|
+
const doc = useDocument(repo, featureDir, file);
|
|
63
|
+
return (
|
|
64
|
+
<Modal title={file ? `${FILE_LABELS[file] ?? file}(${file})` : ""} open={file !== null} onOpenChange={(open) => (open ? undefined : onClose())}>
|
|
65
|
+
<Modal.Content>
|
|
66
|
+
<Status theme={theme} loading={doc.isPending} error={doc.error} />
|
|
67
|
+
{doc.data ? (
|
|
68
|
+
<>
|
|
69
|
+
<Markdown theme={theme} source={doc.data.file.endsWith(".tsv") ? tsvToMarkdown(doc.data.content) : doc.data.content} />
|
|
70
|
+
{doc.data.truncated ? <Text style={s.muted}>文件较大,只显示了开头的 512 KB。</Text> : null}
|
|
71
|
+
</>
|
|
72
|
+
) : null}
|
|
73
|
+
</Modal.Content>
|
|
74
|
+
</Modal>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function ActionModal({
|
|
79
|
+
theme,
|
|
80
|
+
layout,
|
|
81
|
+
detail,
|
|
82
|
+
action,
|
|
83
|
+
onClose,
|
|
84
|
+
}: {
|
|
85
|
+
theme: Theme;
|
|
86
|
+
layout: Layout;
|
|
87
|
+
detail: RequirementDetail;
|
|
88
|
+
action: Action | null;
|
|
89
|
+
onClose(): void;
|
|
90
|
+
}) {
|
|
91
|
+
const s = useStyles(theme, layout.compact);
|
|
92
|
+
const run = useAction();
|
|
93
|
+
const toast = useToast();
|
|
94
|
+
const go = async (verifyEnv?: "local" | "deployed") => {
|
|
95
|
+
if (!action) return;
|
|
96
|
+
try {
|
|
97
|
+
const result = await run.mutateAsync({ action, repo: detail.sourceRepo ?? detail.repo, featureDir: detail.featureDir, verifyEnv });
|
|
98
|
+
const first = result.message.split("\n").find((l) => l.trim()) ?? "";
|
|
99
|
+
if (result.ok) toast.show(first || "已完成", { variant: "success", durationMs: 4000 });
|
|
100
|
+
else toast.error(first || "没有完成");
|
|
101
|
+
onClose();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
toast.error((error as Error).message);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const text = action ? ACTION_TEXT[action] : null;
|
|
107
|
+
return (
|
|
108
|
+
<Modal title={text ? `${text.label}:${detail.title}` : ""} open={action !== null} onOpenChange={(open) => (open ? undefined : onClose())}>
|
|
109
|
+
<Modal.Content>
|
|
110
|
+
<Text style={s.text}>{text?.confirm}</Text>
|
|
111
|
+
{action === "approve" && detail.files.includes("preview.md") ? <Text style={s.muted}>确认预览在本页的"文档"中:确认预览(preview.md)。</Text> : null}
|
|
112
|
+
<View style={s.row}>
|
|
113
|
+
{action === "verify" ? (
|
|
114
|
+
<>
|
|
115
|
+
<Button theme={theme} primary label="在本地验证" disabled={run.isPending} onPress={() => void go("local")} />
|
|
116
|
+
<Button theme={theme} label="在已部署的测试环境验证" disabled={run.isPending} onPress={() => void go("deployed")} />
|
|
117
|
+
</>
|
|
118
|
+
) : (
|
|
119
|
+
<Button theme={theme} primary label={run.isPending ? "正在执行…" : (text?.label ?? "")} disabled={run.isPending} onPress={() => void go()} />
|
|
120
|
+
)}
|
|
121
|
+
<Button theme={theme} label="取消" disabled={run.isPending} onPress={onClose} />
|
|
122
|
+
</View>
|
|
123
|
+
</Modal.Content>
|
|
124
|
+
</Modal>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function GateSummary({ theme, layout, detail }: { theme: Theme; layout: Layout; detail: RequirementDetail }) {
|
|
129
|
+
const s = useStyles(theme, layout.compact);
|
|
130
|
+
const runs = [...(detail.gate?.runs ?? []), ...detail.joint.flatMap((j) => j.gate?.runs ?? [])];
|
|
131
|
+
if (runs.length === 0) return null;
|
|
132
|
+
const last = runs.at(-1)!;
|
|
133
|
+
const failed = runs.filter((r) => !r.passed).length;
|
|
134
|
+
return (
|
|
135
|
+
<View style={s.card}>
|
|
136
|
+
<Text style={s.heading}>质量门</Text>
|
|
137
|
+
<Text style={s.text}>
|
|
138
|
+
运行 {runs.length} 次{failed > 0 ? `,其中 ${failed} 次发现改动带来的新失败` : ",都没有新失败"}
|
|
139
|
+
</Text>
|
|
140
|
+
<Text style={s.muted}>
|
|
141
|
+
最近一次({last.key}):{last.passed ? "通过" : `新失败:${last.newFailures.join("、")}`}
|
|
142
|
+
</Text>
|
|
143
|
+
</View>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function RequirementView({
|
|
148
|
+
theme,
|
|
149
|
+
layout,
|
|
150
|
+
navigation,
|
|
151
|
+
repo,
|
|
152
|
+
featureDir,
|
|
153
|
+
}: {
|
|
154
|
+
theme: Theme;
|
|
155
|
+
layout: Layout;
|
|
156
|
+
navigation: Navigation;
|
|
157
|
+
repo: string;
|
|
158
|
+
featureDir: string;
|
|
159
|
+
}) {
|
|
160
|
+
const s = useStyles(theme, layout.compact);
|
|
161
|
+
const query = useRequirement(repo, featureDir);
|
|
162
|
+
const { readOnly } = usePreferences();
|
|
163
|
+
const [doc, setDoc] = useState<string | null>(null);
|
|
164
|
+
const [action, setAction] = useState<Action | null>(null);
|
|
165
|
+
const [allSteps, setAllSteps] = useState(false);
|
|
166
|
+
const d = query.data;
|
|
167
|
+
if (!d) return <Status theme={theme} loading={query.isPending} error={query.error} />;
|
|
168
|
+
const steps = allSteps ? d.steps : d.steps.slice(-12);
|
|
169
|
+
const actions = readOnly ? [] : actionsFor(d);
|
|
170
|
+
return (
|
|
171
|
+
<View style={{ gap: 16 }}>
|
|
172
|
+
<View style={{ gap: 4 }}>
|
|
173
|
+
<View style={s.row}>
|
|
174
|
+
<Text style={s.title}>{d.title}</Text>
|
|
175
|
+
{d.attention ? (
|
|
176
|
+
<Badge theme={theme} tone={d.attention === "approval" || d.attention === "business" ? "warning" : "danger"}>
|
|
177
|
+
{ATTENTION_LABELS[d.attention]}
|
|
178
|
+
</Badge>
|
|
179
|
+
) : null}
|
|
180
|
+
</View>
|
|
181
|
+
<Text style={s.muted}>{facts(d)}</Text>
|
|
182
|
+
<Text style={s.text}>{d.stage}</Text>
|
|
183
|
+
{d.run?.stopReason && !d.running ? <Text style={s.danger}>{d.run.stopReason}</Text> : null}
|
|
184
|
+
{d.attention === "business" ? <Text style={s.muted}>需求方的决定请在发起需求的对话中回答,回答后再继续。</Text> : null}
|
|
185
|
+
</View>
|
|
186
|
+
|
|
187
|
+
{actions.length > 0 ? (
|
|
188
|
+
<View style={s.row}>
|
|
189
|
+
{actions.map((a) => (
|
|
190
|
+
<Button key={a} theme={theme} primary={a !== "stop"} label={ACTION_TEXT[a].label} onPress={() => setAction(a)} />
|
|
191
|
+
))}
|
|
192
|
+
</View>
|
|
193
|
+
) : null}
|
|
194
|
+
|
|
195
|
+
{d.files.length > 0 ? (
|
|
196
|
+
<View style={{ gap: 6 }}>
|
|
197
|
+
<Text style={s.heading}>文档</Text>
|
|
198
|
+
<View style={s.row}>
|
|
199
|
+
{d.files.map((f) => (
|
|
200
|
+
<Chip key={f} theme={theme} label={FILE_LABELS[f] ?? f} active={false} onPress={() => setDoc(f)} />
|
|
201
|
+
))}
|
|
202
|
+
</View>
|
|
203
|
+
</View>
|
|
204
|
+
) : null}
|
|
205
|
+
|
|
206
|
+
<View style={s.card}>
|
|
207
|
+
<Text style={s.heading}>位置</Text>
|
|
208
|
+
<Text selectable style={s.text}>
|
|
209
|
+
{d.repo}/{d.featureDir}
|
|
210
|
+
</Text>
|
|
211
|
+
{d.branch ? <Text style={s.muted}>分支 {d.branch}</Text> : null}
|
|
212
|
+
{d.workspaceId && navigation ? (
|
|
213
|
+
<Pressable accessibilityRole="button" onPress={() => navigation.openWorkspace({ workspaceId: d.workspaceId! })}>
|
|
214
|
+
<Text style={{ color: theme.colors.accent, fontSize: 13 }}>打开这个需求的工作区</Text>
|
|
215
|
+
</Pressable>
|
|
216
|
+
) : null}
|
|
217
|
+
{d.joint.map((j) => (
|
|
218
|
+
<Text key={j.name} style={s.muted}>
|
|
219
|
+
联合开发「{j.name}」:{j.repo}(分支 {j.branch})
|
|
220
|
+
</Text>
|
|
221
|
+
))}
|
|
222
|
+
</View>
|
|
223
|
+
|
|
224
|
+
{(d.external ?? []).length > 0 ? (
|
|
225
|
+
<View style={s.card}>
|
|
226
|
+
<Text style={s.heading}>外部系统</Text>
|
|
227
|
+
{(d.external ?? []).map((e) => (
|
|
228
|
+
<View key={e.name} style={{ gap: 2 }}>
|
|
229
|
+
<View style={s.row}>
|
|
230
|
+
<Text style={s.text}>
|
|
231
|
+
{e.name}:{e.id ?? "还没有建卡"}
|
|
232
|
+
</Text>
|
|
233
|
+
{e.status ? (
|
|
234
|
+
<Badge theme={theme} tone="muted">
|
|
235
|
+
{e.status}
|
|
236
|
+
</Badge>
|
|
237
|
+
) : null}
|
|
238
|
+
</View>
|
|
239
|
+
{e.url ? <ExternalLink href={e.url}>打开卡片</ExternalLink> : null}
|
|
240
|
+
{e.pending > 0 ? (
|
|
241
|
+
<Text style={s.danger}>
|
|
242
|
+
有 {e.pending} 次调用没有成功:{e.lastError}
|
|
243
|
+
</Text>
|
|
244
|
+
) : null}
|
|
245
|
+
</View>
|
|
246
|
+
))}
|
|
247
|
+
</View>
|
|
248
|
+
) : null}
|
|
249
|
+
|
|
250
|
+
{d.usage ? (
|
|
251
|
+
<View style={s.card}>
|
|
252
|
+
<Text style={s.heading}>用时与用量</Text>
|
|
253
|
+
<Text style={s.text}>
|
|
254
|
+
{d.usage.steps} 个步骤,agent 合计 {minutes(d.usage.agentMinutes)}
|
|
255
|
+
</Text>
|
|
256
|
+
<Text style={s.muted}>
|
|
257
|
+
输入 {tokens(d.usage.inputTokens)} tokens,输出 {tokens(d.usage.outputTokens)} tokens
|
|
258
|
+
{d.usage.costUsd !== null ? `,约 $${d.usage.costUsd.toFixed(2)}` : ""}
|
|
259
|
+
</Text>
|
|
260
|
+
</View>
|
|
261
|
+
) : null}
|
|
262
|
+
|
|
263
|
+
<GateSummary theme={theme} layout={layout} detail={d} />
|
|
264
|
+
|
|
265
|
+
{d.steps.length > 0 ? (
|
|
266
|
+
<View style={s.card}>
|
|
267
|
+
<View style={s.row}>
|
|
268
|
+
<Text style={s.heading}>步骤({d.steps.length})</Text>
|
|
269
|
+
{d.steps.length > 12 ? <Button theme={theme} label={allSteps ? "只看最近的" : "显示全部"} onPress={() => setAllSteps((v) => !v)} /> : null}
|
|
270
|
+
</View>
|
|
271
|
+
{steps.map((step) => (
|
|
272
|
+
<StepRow key={`${step.key}-${step.attempt}-${step.startedAt}`} theme={theme} layout={layout} step={step} navigation={navigation} />
|
|
273
|
+
))}
|
|
274
|
+
</View>
|
|
275
|
+
) : null}
|
|
276
|
+
|
|
277
|
+
<DocumentModal theme={theme} layout={layout} repo={d.repo} featureDir={d.featureDir} file={doc} onClose={() => setDoc(null)} />
|
|
278
|
+
<ActionModal theme={theme} layout={layout} detail={d} action={action} onClose={() => setAction(null)} />
|
|
279
|
+
</View>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function RequirementScreen({ theme, layout, navigation, params }: PluginScreenProps) {
|
|
284
|
+
const s = useStyles(theme, layout.compact);
|
|
285
|
+
// Opened from a step agent's pill: only the requirement's name is known; find it in the list.
|
|
286
|
+
const list = useRequirements();
|
|
287
|
+
const byName = !params.repo && params.name ? (list.data ?? []).find((r) => r.name === params.name) : null;
|
|
288
|
+
const repo = params.repo ?? byName?.repo;
|
|
289
|
+
const featureDir = params.featureDir ?? byName?.featureDir;
|
|
290
|
+
return (
|
|
291
|
+
<View style={s.screen}>
|
|
292
|
+
<ScrollView contentContainerStyle={s.body}>
|
|
293
|
+
{repo && featureDir ? (
|
|
294
|
+
<RequirementView theme={theme} layout={layout} navigation={navigation} repo={repo} featureDir={featureDir} />
|
|
295
|
+
) : params.name ? (
|
|
296
|
+
<Status theme={theme} loading={list.isPending} error={list.error} empty={list.data ? `本机记录中没有需求 ${params.name}。` : null} />
|
|
297
|
+
) : (
|
|
298
|
+
<Text style={s.muted}>没有指定需求。</Text>
|
|
299
|
+
)}
|
|
300
|
+
</ScrollView>
|
|
301
|
+
</View>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Plain-language labels, shared by every screen.
|
|
2
|
+
import type { Attention, Requirement } from "../shared/contracts";
|
|
3
|
+
|
|
4
|
+
export const ATTENTION_LABELS: Record<Exclude<Attention, null>, string> = {
|
|
5
|
+
approval: "等待确认方案",
|
|
6
|
+
business: "等待需求方决定",
|
|
7
|
+
interrupted: "已中断,可以继续",
|
|
8
|
+
not_completed: "结束但未完成",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const TYPE_LABELS: Record<string, string> = { defect: "缺陷", refactor: "重构", assessment: "评估" };
|
|
12
|
+
|
|
13
|
+
export const PROFILE_LABELS: Record<string, string> = { quick: "快速档", standard: "标准档" };
|
|
14
|
+
|
|
15
|
+
export const FILE_LABELS: Record<string, string> = {
|
|
16
|
+
"spec.md": "需求说明",
|
|
17
|
+
"alignment.md": "访谈记录",
|
|
18
|
+
"spec-review.md": "需求说明审阅",
|
|
19
|
+
"changes.md": "需求变更",
|
|
20
|
+
"decisions.tsv": "决策记录",
|
|
21
|
+
"design.md": "方案",
|
|
22
|
+
"design-review.md": "方案审核",
|
|
23
|
+
"preview.md": "确认预览",
|
|
24
|
+
"contract.md": "接口约定",
|
|
25
|
+
"diagnosis.md": "复现与定位",
|
|
26
|
+
"assessment.md": "评估",
|
|
27
|
+
"implementation.md": "实现记录",
|
|
28
|
+
"progress.md": "进展",
|
|
29
|
+
"code-review.md": "代码评审",
|
|
30
|
+
"handoff.md": "提测说明",
|
|
31
|
+
"service-start.md": "服务启动",
|
|
32
|
+
"verification.md": "功能验证",
|
|
33
|
+
"report.md": "完成报告",
|
|
34
|
+
"acceptance.md": "验收记录",
|
|
35
|
+
"lessons.tsv": "可以变成规则的问题",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type Group = "attention" | "running" | "verify" | "done" | "other";
|
|
39
|
+
|
|
40
|
+
export const GROUP_LABELS: Record<Group, string> = {
|
|
41
|
+
attention: "需要你处理",
|
|
42
|
+
running: "进行中",
|
|
43
|
+
verify: "研发完成,待测试验证",
|
|
44
|
+
done: "最近完成",
|
|
45
|
+
other: "其他",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const RECENT_MS = 14 * 24 * 3600_000;
|
|
49
|
+
|
|
50
|
+
export function groupOf(r: Requirement, now = Date.now()): Group {
|
|
51
|
+
if (r.attention) return "attention";
|
|
52
|
+
if (r.running) return "running";
|
|
53
|
+
if (r.status === "phase_done") return "verify";
|
|
54
|
+
if (r.status === "completed" && now - Date.parse(r.updatedAt) < RECENT_MS) return "done";
|
|
55
|
+
return "other";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function minutes(m: number): string {
|
|
59
|
+
if (m < 1) return "不到 1 分钟";
|
|
60
|
+
if (m < 60) return `${Math.round(m)} 分钟`;
|
|
61
|
+
const h = Math.floor(m / 60);
|
|
62
|
+
const rest = Math.round(m % 60);
|
|
63
|
+
return rest === 0 ? `${h} 小时` : `${h} 小时 ${rest} 分`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function ago(iso: string, now = Date.now()): string {
|
|
67
|
+
const m = (now - Date.parse(iso)) / 60_000;
|
|
68
|
+
if (!Number.isFinite(m)) return "";
|
|
69
|
+
if (m < 1) return "刚刚";
|
|
70
|
+
if (m < 60) return `${Math.round(m)} 分钟前`;
|
|
71
|
+
if (m < 24 * 60) return `${Math.round(m / 60)} 小时前`;
|
|
72
|
+
return `${Math.round(m / 60 / 24)} 天前`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function tokens(n: number | null): string {
|
|
76
|
+
if (n === null) return "未报告";
|
|
77
|
+
return n >= 10_000 ? `${Math.round(n / 1_000) / 10} 万` : String(n);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One line under a requirement's title: project, type, stage. */
|
|
81
|
+
export function facts(r: Requirement): string {
|
|
82
|
+
return [r.project, r.type ? TYPE_LABELS[r.type] ?? r.type : null, r.profile ? PROFILE_LABELS[r.profile] ?? r.profile : null]
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.join(" · ");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What a person can do from here, by the requirement's state. */
|
|
88
|
+
export function actionsFor(
|
|
89
|
+
r: Pick<Requirement, "attention" | "running" | "status"> & { external?: Array<{ pending: number; id: string | null }> },
|
|
90
|
+
): Array<"approve" | "stop" | "resume" | "verify" | "sync"> {
|
|
91
|
+
const base: Array<"approve" | "stop" | "resume" | "verify" | "sync"> = r.running
|
|
92
|
+
? ["stop"]
|
|
93
|
+
: r.attention === "approval"
|
|
94
|
+
? ["approve"]
|
|
95
|
+
: r.attention === "interrupted" || r.attention === "business"
|
|
96
|
+
? ["resume"]
|
|
97
|
+
: r.status === "phase_done"
|
|
98
|
+
? ["verify"]
|
|
99
|
+
: [];
|
|
100
|
+
// A running pipeline sends what failed by itself at its next step change.
|
|
101
|
+
if (!r.running && r.status && (r.external ?? []).some((e) => e.pending > 0 || !e.id)) base.push("sync");
|
|
102
|
+
return base;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const ACTION_TEXT = {
|
|
106
|
+
approve: { label: "确认方案", confirm: "确认 preview.md 中的方案,开始写代码?请先看过确认预览和原型。" },
|
|
107
|
+
stop: { label: "停止", confirm: "停止这个需求的流水线?正在运行的步骤会被中断,之后可以继续。" },
|
|
108
|
+
resume: { label: "继续", confirm: "从中断处继续?需求说明改过的话,只处理变化的部分。" },
|
|
109
|
+
verify: { label: "开始测试验证", confirm: "开始测试验证?选择在哪里验证。" },
|
|
110
|
+
sync: { label: "同步到外部系统", confirm: "把这个需求同步到对接的外部系统:补发没有成功的调用、补建卡片、更新状态。" },
|
|
111
|
+
} as const;
|