paseo-req-kit 0.0.0-stage → 1.7.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 +217 -0
- package/LICENSE +21 -0
- package/README.md +141 -2
- package/SCOPE.md +11 -0
- package/bin/locate.mjs +46 -0
- package/bin/req-doctor.mjs +21 -0
- package/bin/req.mjs +175 -0
- package/bin/start-pipeline +21 -0
- package/bin/start-pipeline.mjs +22 -0
- package/config.example.json +101 -0
- package/contracts/README.md +18 -0
- package/contracts/feature-files.md +87 -0
- package/contracts/notifier-event.md +71 -0
- package/contracts/result-file.md +17 -0
- package/contracts/spec.md +34 -0
- package/contracts/state.md +44 -0
- package/contracts/verify.md +39 -0
- package/docs/adr/0001-step-agents-under-origin-chat.md +15 -0
- package/docs/adr/0002-no-directory-switch-in-chat.md +15 -0
- package/docs/adr/0003-script-engine-not-hub.md +15 -0
- package/docs/adr/0004-result-files.md +15 -0
- package/docs/adr/0005-no-default-branch-local-commits-only.md +15 -0
- package/docs/adr/0006-skill-install-targets.md +15 -0
- package/docs/adr/0007-own-interview-skill.md +15 -0
- package/docs/adr/0008-entries-do-not-call-entries.md +15 -0
- package/docs/adr/0009-versioned-install-layout.md +15 -0
- package/docs/adr/0010-generated-feature-overview.md +15 -0
- package/docs/adr/0011-resume-after-completion.md +15 -0
- package/docs/adr/0012-review-panel.md +18 -0
- package/docs/adr/0013-no-release-tooling.md +15 -0
- package/docs/adr/0014-notifier-slot.md +19 -0
- package/docs/adr/0015-incremental-continue.md +17 -0
- package/docs/adr/0016-upgrade-from-tags.md +19 -0
- package/docs/adr/0017-use-paseo-capabilities.md +18 -0
- package/docs/adr/0018-own-worktree-option.md +21 -0
- package/docs/adr/0019-parallel-orchestration.md +21 -0
- package/docs/adr/0020-scheduling-through-the-chat.md +21 -0
- package/docs/adr/0021-npm-version-manager.md +19 -0
- package/docs/adr/0022-develop-and-verify-phases.md +32 -0
- package/docs/adr/README.md +26 -0
- package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +78 -0
- package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +348 -0
- package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +85 -0
- 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 +251 -0
- package/engine/prompts/00-shared-context.md +100 -0
- package/engine/prompts/02-spec-review.md +46 -0
- package/engine/prompts/03-design.md +50 -0
- package/engine/prompts/03a-design-proposal.md +30 -0
- package/engine/prompts/04-design-review.md +72 -0
- package/engine/prompts/04b-design-revise.md +34 -0
- package/engine/prompts/05-implement.md +66 -0
- package/engine/prompts/06-code-review.md +66 -0
- package/engine/prompts/07-review-fix.md +41 -0
- package/engine/prompts/07b-handoff.md +45 -0
- package/engine/prompts/08-service-start.md +59 -0
- package/engine/prompts/09-verify.md +90 -0
- package/engine/prompts/10-defect-fix.md +41 -0
- package/engine/prompts/10b-reverify.md +43 -0
- package/engine/prompts/11-report.md +93 -0
- package/engine/prompts/browser-verify.md +13 -0
- package/engine/prompts/incremental.md +21 -0
- package/engine/prompts/panel-member.md +38 -0
- package/engine/prompts/paseo-services.md +9 -0
- package/engine/prompts/verify-deployed.md +14 -0
- package/engine/src/activity.ts +87 -0
- package/engine/src/cli-args.ts +204 -0
- package/engine/src/config.ts +479 -0
- package/engine/src/configure.ts +237 -0
- package/engine/src/control.ts +230 -0
- package/engine/src/daemon-check.ts +104 -0
- package/engine/src/daemon-version.ts +66 -0
- package/engine/src/digest.ts +133 -0
- package/engine/src/doctor.ts +435 -0
- package/engine/src/eval.ts +324 -0
- package/engine/src/features.ts +268 -0
- package/engine/src/git.ts +30 -0
- package/engine/src/launch.ts +405 -0
- package/engine/src/locations.ts +55 -0
- package/engine/src/main.ts +313 -0
- package/engine/src/messages.ts +308 -0
- package/engine/src/notifiers.ts +76 -0
- package/engine/src/notify-test.ts +97 -0
- package/engine/src/paseo-json.ts +73 -0
- package/engine/src/paseo-profiles.ts +133 -0
- package/engine/src/paseo-runtime.ts +437 -0
- package/engine/src/paths.ts +10 -0
- package/engine/src/pipeline.ts +1126 -0
- package/engine/src/preflight.ts +298 -0
- package/engine/src/prompts.ts +72 -0
- package/engine/src/queue.ts +61 -0
- package/engine/src/runtime-loader.ts +14 -0
- package/engine/src/runtime.ts +155 -0
- package/engine/src/schedule-time.ts +51 -0
- package/engine/src/smoke-test.ts +202 -0
- package/engine/src/spec-review.ts +100 -0
- package/engine/src/state.ts +278 -0
- package/engine/src/steps.ts +242 -0
- package/engine/src/usage.ts +67 -0
- package/engine/src/worktree.ts +72 -0
- package/install.mjs +928 -0
- package/notifiers/im-template.mjs +58 -0
- package/package.json +67 -4
- package/principles/INDEX.md +11 -0
- package/skills/acceptance-criteria/SKILL.md +52 -0
- package/skills/principle-delete-before-add/SKILL.md +33 -0
- package/skills/principle-dont-block-on-humans/SKILL.md +35 -0
- package/skills/principle-fix-root-cause/SKILL.md +33 -0
- package/skills/principle-prove-it-works/SKILL.md +33 -0
- package/skills/principle-report-honestly/SKILL.md +33 -0
- package/skills/principle-speak-to-reader/SKILL.md +33 -0
- package/skills/principle-this-requirement-only/SKILL.md +33 -0
- package/skills/req/SKILL.md +99 -0
- package/skills/req/playbooks/accept.md +65 -0
- package/skills/req/playbooks/change.md +51 -0
- package/skills/req/playbooks/incident.md +55 -0
- package/skills/req/playbooks/new.md +83 -0
- package/skills/req/playbooks/small.md +48 -0
- package/skills/req/playbooks/status.md +39 -0
- package/skills/req/playbooks/takeover.md +51 -0
- package/skills/req/playbooks/verify.md +57 -0
- package/skills/req-doctor/SKILL.md +36 -0
- package/skills/req-retro/SKILL.md +79 -0
- package/skills/req-setup/SKILL.md +101 -0
- package/skills/req-setup/templates/paseo/copy-local-files.mjs +32 -0
- package/skills/req-setup/templates/paseo/with-port.mjs +36 -0
- package/skills/requirement-interview/SKILL.md +94 -0
- package/skills/requirement-kickoff/SKILL.md +17 -0
- package/skills/result-report/SKILL.md +34 -0
- package/skills/spec-writing/SKILL.md +41 -0
- package/skills/spec-writing/spec-template.md +106 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
type DefectHandling,
|
|
6
|
+
PHASE_MODE_LABELS,
|
|
7
|
+
PHASE_MODES,
|
|
8
|
+
type PhaseMode,
|
|
9
|
+
PROFILE_LABELS,
|
|
10
|
+
PROFILE_NAMES,
|
|
11
|
+
type ProfileName,
|
|
12
|
+
type VerifyEnvironment,
|
|
13
|
+
} from "./config.js";
|
|
14
|
+
import { gitInfo } from "./git.js";
|
|
15
|
+
import { samePath } from "./paths.js";
|
|
16
|
+
import { currentLockHolder, type PipelineState, readState, statePath } from "./state.js";
|
|
17
|
+
|
|
18
|
+
export interface LaunchArgs {
|
|
19
|
+
repo: string;
|
|
20
|
+
featureDir: string;
|
|
21
|
+
originAgentId: string | null;
|
|
22
|
+
resume: boolean;
|
|
23
|
+
restart: boolean;
|
|
24
|
+
configPath: string | null;
|
|
25
|
+
/** Explicit --profile; otherwise the spec's "档位" line, then the configured default. */
|
|
26
|
+
profile: ProfileName | null;
|
|
27
|
+
status: boolean;
|
|
28
|
+
stop: boolean;
|
|
29
|
+
/** --list: print every requirement in this repository's worktrees. */
|
|
30
|
+
list: boolean;
|
|
31
|
+
/** --spec-review: independent review of the draft spec, before confirmation. */
|
|
32
|
+
specReview: boolean;
|
|
33
|
+
/** --worktree: create a Paseo worktree workspace for this requirement and develop there. */
|
|
34
|
+
worktree: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* The checkout the chat works in, when the pipeline runs in a worktree created for it
|
|
37
|
+
* (repo is then that worktree). Set by the launcher; the chat's cwd must be this.
|
|
38
|
+
*/
|
|
39
|
+
sourceRepo: string | null;
|
|
40
|
+
/** Paseo workspace of that worktree. */
|
|
41
|
+
workspaceId: string | null;
|
|
42
|
+
/** --at: schedule the start for later instead of starting now. */
|
|
43
|
+
at: string | null;
|
|
44
|
+
/** --phases: execution mode for this run; otherwise the spec's "执行方式" line, then the config. */
|
|
45
|
+
phases: PhaseMode | null;
|
|
46
|
+
/** --phase verify: start (or continue) the verify phase. Implies --resume. */
|
|
47
|
+
phase: "verify" | null;
|
|
48
|
+
/** --verify-at: once development is done, start the verify phase at this time. */
|
|
49
|
+
verifyAt: string | null;
|
|
50
|
+
/** --verify-env: where the verify phase checks the result (overrides phases.verify.environment). */
|
|
51
|
+
verifyEnv: VerifyEnvironment | null;
|
|
52
|
+
/** --defects: what the verify phase does with defects (overrides phases.verify.defects). */
|
|
53
|
+
defects: DefectHandling | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class PreflightError extends Error {
|
|
57
|
+
constructor(message: string) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = "PreflightError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function gitCommonDir(dir: string): string | null {
|
|
64
|
+
try {
|
|
65
|
+
const out = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
|
|
66
|
+
cwd: dir,
|
|
67
|
+
encoding: "utf8",
|
|
68
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
69
|
+
}).trim();
|
|
70
|
+
return out ? fs.realpathSync(out) : null;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether `worktree` is a git worktree of the same repository as `source`. */
|
|
77
|
+
export function sameRepository(worktree: string, source: string): boolean {
|
|
78
|
+
const a = gitCommonDir(worktree);
|
|
79
|
+
const b = gitCommonDir(source);
|
|
80
|
+
return a !== null && b !== null && samePath(a, b);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Step agents run under the launching chat. Normally they work in the chat's own
|
|
85
|
+
* directory, so the repository must be it. A requirement developed in a worktree
|
|
86
|
+
* created for it (--worktree) runs in that worktree instead; the chat must then be
|
|
87
|
+
* in the checkout the worktree was created from.
|
|
88
|
+
*/
|
|
89
|
+
export function checkChatCwd(args: Pick<LaunchArgs, "originAgentId" | "sourceRepo">, repo: string, env: NodeJS.ProcessEnv = process.env): void {
|
|
90
|
+
const chatCwd = env.PASEO_AGENT_CWD?.trim();
|
|
91
|
+
if (!args.originAgentId || !chatCwd || env.PASEO_AGENT_ID?.trim() !== args.originAgentId) return;
|
|
92
|
+
const chat = fs.existsSync(chatCwd) ? fs.realpathSync(chatCwd) : chatCwd;
|
|
93
|
+
if (args.sourceRepo) {
|
|
94
|
+
const source = fs.existsSync(args.sourceRepo) ? fs.realpathSync(args.sourceRepo) : args.sourceRepo;
|
|
95
|
+
if (samePath(chat, source) && sameRepository(repo, source)) return;
|
|
96
|
+
throw new PreflightError(
|
|
97
|
+
`这个需求在为它新建的工作区 ${repo} 中开发,只能从 ${source} 的对话中操作,当前对话的工作目录是 ${chatCwd}。`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (!samePath(chat, repo)) {
|
|
101
|
+
throw new PreflightError(
|
|
102
|
+
`仓库目录 ${repo} 与当前对话的工作目录 ${chatCwd} 不一致。` +
|
|
103
|
+
"流水线的步骤会挂在这个对话下、在对话的工作目录中进行,所以只能为对话所在的项目启动。" +
|
|
104
|
+
"请在该仓库对应的项目中打开对话后再启动;对话过程中不要切换工作目录。",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The spec must carry the line written by the kickoff skill after the user confirms it. */
|
|
110
|
+
export function isSpecConfirmed(specText: string): boolean {
|
|
111
|
+
return /^\s*-\s*状态\s*[::]\s*已确认/m.test(specText);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function normalizeFeatureDir(repo: string, featureDir: string): string {
|
|
115
|
+
const absolute = path.resolve(repo, featureDir);
|
|
116
|
+
const relative = path.relative(repo, absolute);
|
|
117
|
+
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
118
|
+
throw new PreflightError(`功能目录必须位于仓库目录之内:${featureDir}`);
|
|
119
|
+
}
|
|
120
|
+
return relative.split(path.sep).join("/");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type StartMode =
|
|
124
|
+
| { kind: "fresh" }
|
|
125
|
+
| { kind: "restart"; previous: PipelineState }
|
|
126
|
+
| { kind: "resume-reuse"; previous: PipelineState }
|
|
127
|
+
| { kind: "resume-from-design"; previous: PipelineState; reason: string };
|
|
128
|
+
|
|
129
|
+
export interface PreflightResult {
|
|
130
|
+
repo: string;
|
|
131
|
+
featureDir: string;
|
|
132
|
+
specPath: string;
|
|
133
|
+
mode: StartMode;
|
|
134
|
+
/** Branch the run will commit to; null outside a git repository. */
|
|
135
|
+
branch: string | null;
|
|
136
|
+
/** Profile named in the spec, if any. */
|
|
137
|
+
specProfile: ProfileName | null;
|
|
138
|
+
/** Execution mode named in the spec, if any. */
|
|
139
|
+
specPhases: PhaseMode | null;
|
|
140
|
+
/** This start begins the verify phase (the develop phase is done). */
|
|
141
|
+
entersVerify: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Reads "- 执行方式:分阶段" (连续 / 分阶段 / 只做研发, or the English names) from the spec header. */
|
|
145
|
+
export function specPhases(specText: string): PhaseMode | null {
|
|
146
|
+
const match = /^\s*-\s*执行方式\s*[::]\s*(\S+)/m.exec(specText);
|
|
147
|
+
if (!match) return null;
|
|
148
|
+
for (const mode of PHASE_MODES) {
|
|
149
|
+
if (match[1].startsWith(PHASE_MODE_LABELS[mode]) || match[1].toLowerCase().startsWith(mode)) return mode;
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Whether a run has finished its develop phase, so the verify phase can start. */
|
|
155
|
+
export function developDone(state: PipelineState): boolean {
|
|
156
|
+
return state.status === "phase_done" || (state.status === "completed" && state.phases?.mode === "develop-only");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Reads "- 档位:快速" / "- 档位:标准" from the spec header. */
|
|
160
|
+
export function specProfile(specText: string): ProfileName | null {
|
|
161
|
+
const match = /^\s*-\s*档位\s*[::]\s*(\S+)/m.exec(specText);
|
|
162
|
+
if (!match) return null;
|
|
163
|
+
for (const name of PROFILE_NAMES) {
|
|
164
|
+
if (match[1].startsWith(PROFILE_LABELS[name]) || match[1].toLowerCase().startsWith(name)) return name;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Checks everything that can be checked without talking to the daemon.
|
|
171
|
+
* The launcher runs this before detaching so the chat agent gets an immediate,
|
|
172
|
+
* readable error; the pipeline process runs it again before taking the lock.
|
|
173
|
+
*/
|
|
174
|
+
export function preflight(
|
|
175
|
+
args: LaunchArgs,
|
|
176
|
+
currentSpecHash: (file: string) => string,
|
|
177
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
178
|
+
): PreflightResult {
|
|
179
|
+
if (!fs.existsSync(args.repo) || !fs.statSync(args.repo).isDirectory()) {
|
|
180
|
+
throw new PreflightError(`仓库目录不存在:${args.repo}`);
|
|
181
|
+
}
|
|
182
|
+
const repo = fs.realpathSync(args.repo);
|
|
183
|
+
checkChatCwd(args, repo, env);
|
|
184
|
+
const featureDir = normalizeFeatureDir(repo, args.featureDir);
|
|
185
|
+
const featureAbs = path.join(repo, featureDir);
|
|
186
|
+
if (!fs.existsSync(featureAbs) || !fs.statSync(featureAbs).isDirectory()) {
|
|
187
|
+
throw new PreflightError(`功能目录不存在:${featureDir}`);
|
|
188
|
+
}
|
|
189
|
+
const specPath = path.join(featureAbs, "spec.md");
|
|
190
|
+
if (!fs.existsSync(specPath)) {
|
|
191
|
+
throw new PreflightError(`功能目录中没有 spec.md:${featureDir}`);
|
|
192
|
+
}
|
|
193
|
+
const specText = fs.readFileSync(specPath, "utf8");
|
|
194
|
+
if (!isSpecConfirmed(specText)) {
|
|
195
|
+
throw new PreflightError(
|
|
196
|
+
'spec.md 还没有被确认(顶部需要有 "- 状态:已确认" 这一行)。请先和用户确认需求说明。',
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if (args.resume && args.restart) {
|
|
200
|
+
throw new PreflightError("--resume 和 --restart 不能同时使用");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const holder = currentLockHolder(repo);
|
|
204
|
+
if (holder) {
|
|
205
|
+
throw new PreflightError(
|
|
206
|
+
`这个仓库目录已有一条流水线在运行(进程 ${holder.pid},功能目录 ${holder.featureDir})。同一个目录一次只能运行一条。`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const previous = readState(statePath(repo, featureDir));
|
|
211
|
+
let mode: StartMode;
|
|
212
|
+
if (!previous) {
|
|
213
|
+
if (args.resume) throw new PreflightError("没有找到之前的运行记录,无法继续。去掉 --resume 重新启动即可。");
|
|
214
|
+
mode = { kind: "fresh" };
|
|
215
|
+
} else if (args.restart) {
|
|
216
|
+
mode = { kind: "restart", previous };
|
|
217
|
+
} else if (!args.resume) {
|
|
218
|
+
const hint =
|
|
219
|
+
previous.status === "phase_done"
|
|
220
|
+
? "这个需求的研发阶段已经完成,正在等待测试验证。开始测试验证请加 --resume --phase verify(或只加 --resume);要从头开始请加 --restart。"
|
|
221
|
+
: previous.status === "completed"
|
|
222
|
+
? "这个需求的流水线已经完成。如需重新完整运行,请加 --restart。"
|
|
223
|
+
: `这个需求已有运行记录(状态:${previous.status})。从中断处继续请加 --resume,从头开始请加 --restart。`;
|
|
224
|
+
throw new PreflightError(hint);
|
|
225
|
+
} else if (args.phase === "verify" && !developDone(previous) && previous.phase !== "verify") {
|
|
226
|
+
throw new PreflightError(
|
|
227
|
+
previous.status === "completed"
|
|
228
|
+
? "这个需求已经完整走完了研发和测试验证,没有单独的测试阶段可以开始。要重新验证,请更新需求说明后用 --resume,或用 --restart。"
|
|
229
|
+
: `这个需求的研发阶段还没有完成(状态:${previous.status}),还不能开始测试验证。请先用 --resume 继续研发。`,
|
|
230
|
+
);
|
|
231
|
+
} else if (args.phase === "verify" && developDone(previous) && currentSpecHash(specPath) === previous.specHash) {
|
|
232
|
+
// Development is done and the requirement has not changed since: the verify phase starts.
|
|
233
|
+
mode = { kind: "resume-reuse", previous };
|
|
234
|
+
} else if (previous.status === "phase_done" && currentSpecHash(specPath) === previous.specHash) {
|
|
235
|
+
// A plain --resume at the gate starts the verify phase too.
|
|
236
|
+
mode = { kind: "resume-reuse", previous };
|
|
237
|
+
} else if (previous.status === "completed") {
|
|
238
|
+
// A completed requirement is reopened by changing its spec (a requirement change, or
|
|
239
|
+
// problems found during acceptance). Without such a change there is nothing to continue.
|
|
240
|
+
if (currentSpecHash(specPath) === previous.specHash) {
|
|
241
|
+
if (previous.phases?.mode === "develop-only") {
|
|
242
|
+
throw new PreflightError(
|
|
243
|
+
"这个需求按\"只做研发\"完成了研发,没有可继续的步骤。要让流水线做测试验证,请用 --resume --phase verify;要修改需求,请先更新并重新确认 spec.md。",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
throw new PreflightError(
|
|
247
|
+
"这个需求的流水线已经完成,没有可继续的步骤。要修改需求或修复验收中发现的问题,请先更新并重新确认 spec.md;要原样重跑一遍,请用 --restart。",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
mode = { kind: "resume-from-design", previous, reason: "流水线完成后 spec.md 被修改过(需求变更或验收发现的问题),从方案设计开始增量处理" };
|
|
251
|
+
} else if (previous.status === "waiting_for_business") {
|
|
252
|
+
mode = { kind: "resume-from-design", previous, reason: "上次因等待需求方决定而停止,需求可能已更新,从方案设计开始增量处理" };
|
|
253
|
+
} else if (currentSpecHash(specPath) !== previous.specHash) {
|
|
254
|
+
mode = { kind: "resume-from-design", previous, reason: "spec.md 在上次运行后被修改过,从方案设计开始增量处理" };
|
|
255
|
+
} else {
|
|
256
|
+
// "failed", or "running" left behind by a process that died without cleanup.
|
|
257
|
+
mode = { kind: "resume-reuse", previous };
|
|
258
|
+
}
|
|
259
|
+
const git = gitInfo(repo);
|
|
260
|
+
if (args.worktree) {
|
|
261
|
+
// The run will happen in a new worktree on its own branch, cut from what is checked out here.
|
|
262
|
+
if (mode.kind !== "fresh") {
|
|
263
|
+
throw new PreflightError("这个需求已经在这里运行过,不能再改为在新工作区中开发。请用 --resume 继续,或去掉 --worktree。");
|
|
264
|
+
}
|
|
265
|
+
if (!git.isRepo) throw new PreflightError("--worktree 需要 git 仓库。");
|
|
266
|
+
return { repo, featureDir, specPath, mode, branch: git.branch, specProfile: specProfile(specText), specPhases: specPhases(specText), entersVerify: false };
|
|
267
|
+
}
|
|
268
|
+
// The run commits to the branch that is checked out now, and never to the default branch.
|
|
269
|
+
if (git.isRepo && git.detached) {
|
|
270
|
+
throw new PreflightError("当前没有处在任何分支上(detached HEAD)。请切换到这个需求的工作分支后再启动。");
|
|
271
|
+
}
|
|
272
|
+
if (git.branch && git.protectedBranches.includes(git.branch)) {
|
|
273
|
+
throw new PreflightError(
|
|
274
|
+
`当前在默认分支 ${git.branch} 上,流水线不会在默认分支上提交代码。` +
|
|
275
|
+
"可以加 --worktree,让流水线为这个需求新建一个 worktree 工作区并在那里开发(这个对话不用动);" +
|
|
276
|
+
"或者在 Paseo 中自己新建一个 worktree 工作区(New workspace → Isolation 选 New worktree),在那里的对话中重新开始;" +
|
|
277
|
+
"或者先切换到这个需求的功能分支。",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (mode.kind === "resume-reuse" && mode.previous.branch && git.branch !== mode.previous.branch) {
|
|
281
|
+
throw new PreflightError(
|
|
282
|
+
`这个需求之前在分支 ${mode.previous.branch} 上进行,现在的分支是 ${git.branch ?? "(无)"}。请切回原分支后继续,或用 --restart 在当前分支上从头开始。`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const entersVerify = mode.kind === "resume-reuse" && developDone(mode.previous);
|
|
286
|
+
return { repo, featureDir, specPath, mode, branch: git.branch, specProfile: specProfile(specText), specPhases: specPhases(specText), entersVerify };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Why the verify phase cannot start in this environment, or null. */
|
|
290
|
+
export function verifyPhaseProblem(repo: string, featureDir: string, environment: VerifyEnvironment): string | null {
|
|
291
|
+
if (environment !== "deployed") return null;
|
|
292
|
+
if (fs.existsSync(path.join(repo, featureDir, "test-env.md"))) return null;
|
|
293
|
+
return (
|
|
294
|
+
`要在已部署的测试环境中验证,但功能目录中还没有 test-env.md(${featureDir}/test-env.md)。` +
|
|
295
|
+
"请先和用户确认测试环境,写好这个文件(地址、账号从哪里取、可以用的测试数据和账号;只写口令在哪里取,不写口令本身),再开始测试验证;" +
|
|
296
|
+
"或者用 --verify-env local 在本地验证。"
|
|
297
|
+
);
|
|
298
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Prompt sources, from least to most specific:
|
|
5
|
+
// <kit>/engine/prompts/<file> shipped with this version
|
|
6
|
+
// <kit home>/prompts.local/<file> machine-wide override; replaces the shipped file, survives upgrades
|
|
7
|
+
// <kit>/principles/INDEX.md principles, also read by the chat skills
|
|
8
|
+
// <repo>/.requirement-pipeline/project-notes.md appended to every step of this project
|
|
9
|
+
// <repo>/.requirement-pipeline/verify.md appended to the steps that build, start or test the project
|
|
10
|
+
// <repo>/.requirement-pipeline/steps/<step>.md appended to one step of this project
|
|
11
|
+
|
|
12
|
+
export const PROJECT_DIR = ".requirement-pipeline";
|
|
13
|
+
|
|
14
|
+
export interface PromptLocations {
|
|
15
|
+
promptsDir: string;
|
|
16
|
+
localPromptsDir: string;
|
|
17
|
+
principlesFile: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PromptSources extends PromptLocations {
|
|
21
|
+
repo: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function readToolPrompt(sources: PromptSources, file: string): string {
|
|
25
|
+
const local = path.join(sources.localPromptsDir, file);
|
|
26
|
+
const shipped = path.join(sources.promptsDir, file);
|
|
27
|
+
return fs.readFileSync(fs.existsSync(local) ? local : shipped, "utf8").trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The shared principles, as a prompt section, or null when the index is missing. */
|
|
31
|
+
export function principlesSection(sources: PromptSources): string | null {
|
|
32
|
+
const text = readIfPresent(sources.principlesFile);
|
|
33
|
+
if (!text) return null;
|
|
34
|
+
// The index starts with its own title; give it a heading that fits inside the prompt.
|
|
35
|
+
const body = text.replace(/^#\s+.*\n+/, "");
|
|
36
|
+
return ["# 工作原则", "", "以下原则在需求对齐阶段和流水线各步骤中通用。具体要求以前文和\"本步骤任务\"为准,这里说明背后的取舍。", "", body].join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readIfPresent(file: string): string | null {
|
|
40
|
+
if (!fs.existsSync(file)) return null;
|
|
41
|
+
const text = fs.readFileSync(file, "utf8").trim();
|
|
42
|
+
return text === "" ? null : text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Steps that build, start or test the project, and so need the project's verification instructions. */
|
|
46
|
+
export const VERIFY_STEPS: readonly string[] = ["implement", "review_fix", "service_start", "verify", "defect_fix", "reverify"];
|
|
47
|
+
|
|
48
|
+
/** Project-specific additions for one step, ready to append, or null when there are none. */
|
|
49
|
+
export function projectAdditions(sources: PromptSources, stepId: string): string | null {
|
|
50
|
+
const notes = readIfPresent(path.join(sources.repo, PROJECT_DIR, "project-notes.md"));
|
|
51
|
+
const verify = VERIFY_STEPS.includes(stepId) ? readIfPresent(path.join(sources.repo, PROJECT_DIR, "verify.md")) : null;
|
|
52
|
+
const step = readIfPresent(path.join(sources.repo, PROJECT_DIR, "steps", `${stepId}.md`));
|
|
53
|
+
if (!notes && !verify && !step) return null;
|
|
54
|
+
const parts = [
|
|
55
|
+
"# 本项目的补充说明",
|
|
56
|
+
"",
|
|
57
|
+
"以下内容由项目维护者编写,说明这个项目的约定和要求。与前文的通用要求冲突时,以这里为准;但\"如实报告\"和安全方面的要求始终有效。",
|
|
58
|
+
];
|
|
59
|
+
if (notes) parts.push("", notes);
|
|
60
|
+
if (verify) {
|
|
61
|
+
parts.push(
|
|
62
|
+
"",
|
|
63
|
+
"## 本项目的验证方法",
|
|
64
|
+
"",
|
|
65
|
+
"来自 `.requirement-pipeline/verify.md`,其中的命令已由项目维护者实际运行过。安装依赖、启动服务、运行测试时优先照此执行;发现它过时或不准确时,照常完成本步骤,并在你的产物里写明哪里不对、实际可行的做法是什么。",
|
|
66
|
+
"",
|
|
67
|
+
verify,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (step) parts.push("", "## 针对本步骤", "", step);
|
|
71
|
+
return parts.join("\n");
|
|
72
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Machine-wide limit on pipelines running at once (maxConcurrentPipelines).
|
|
2
|
+
// A pipeline takes one of N slot files in ~/.req-kit/slots; creating a file with "wx"
|
|
3
|
+
// is atomic, so two pipelines starting together cannot both take the last slot.
|
|
4
|
+
// A slot whose process is gone is free again.
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { kitHome } from "./locations.js";
|
|
9
|
+
import { isAlive } from "./state.js";
|
|
10
|
+
|
|
11
|
+
export interface SlotHolder {
|
|
12
|
+
pid: number;
|
|
13
|
+
repo: string;
|
|
14
|
+
featureDir: string;
|
|
15
|
+
since: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function slotsDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
19
|
+
return path.join(kitHome(env), "slots");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readHolder(file: string): SlotHolder | null {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(file, "utf8")) as SlotHolder;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Pipelines holding a slot now (dead holders are cleared on the way). */
|
|
31
|
+
export function slotHolders(dir: string): SlotHolder[] {
|
|
32
|
+
if (!fs.existsSync(dir)) return [];
|
|
33
|
+
const holders: SlotHolder[] = [];
|
|
34
|
+
for (const name of fs.readdirSync(dir)) {
|
|
35
|
+
const file = path.join(dir, name);
|
|
36
|
+
const holder = readHolder(file);
|
|
37
|
+
if (holder && isAlive(holder.pid)) holders.push(holder);
|
|
38
|
+
else fs.rmSync(file, { force: true });
|
|
39
|
+
}
|
|
40
|
+
return holders;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Takes a free slot; returns a release function, or null when all `max` slots are taken. */
|
|
44
|
+
export function tryAcquireSlot(dir: string, max: number, holder: SlotHolder): (() => void) | null {
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
for (let i = 0; i < max; i++) {
|
|
47
|
+
const file = path.join(dir, `slot-${i}.json`);
|
|
48
|
+
const current = readHolder(file);
|
|
49
|
+
if (current && current.pid !== holder.pid && isAlive(current.pid)) continue;
|
|
50
|
+
if (current) fs.rmSync(file, { force: true });
|
|
51
|
+
try {
|
|
52
|
+
fs.writeFileSync(file, JSON.stringify(holder), { flag: "wx" });
|
|
53
|
+
} catch {
|
|
54
|
+
continue; // taken a moment ago by another pipeline
|
|
55
|
+
}
|
|
56
|
+
return () => {
|
|
57
|
+
if (readHolder(file)?.pid === holder.pid) fs.rmSync(file, { force: true });
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import type { RuntimeFactory } from "./runtime.js";
|
|
4
|
+
|
|
5
|
+
export async function loadRuntimeFactory(): Promise<RuntimeFactory> {
|
|
6
|
+
// Test hook: run the real orchestration against a scripted fake daemon.
|
|
7
|
+
const fake = process.env.REQ_PIPELINE_FAKE_RUNTIME;
|
|
8
|
+
if (fake) {
|
|
9
|
+
const module = (await import(pathToFileURL(path.resolve(fake)).href)) as { createRuntime: RuntimeFactory };
|
|
10
|
+
return module.createRuntime;
|
|
11
|
+
}
|
|
12
|
+
const module = await import("./paseo-runtime.js");
|
|
13
|
+
return module.createRuntime;
|
|
14
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The narrow surface the pipeline needs from Paseo. The real implementation is
|
|
2
|
+
// paseo-runtime.ts; tests swap in a fake through REQ_PIPELINE_FAKE_RUNTIME.
|
|
3
|
+
|
|
4
|
+
export type AgentRole = "builder" | "reviewer";
|
|
5
|
+
|
|
6
|
+
export interface PendingPermission {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WaitOutcome {
|
|
12
|
+
/** Same values as the SDK's waitForFinish(). */
|
|
13
|
+
status: "idle" | "error" | "permission" | "timeout";
|
|
14
|
+
error: string | null;
|
|
15
|
+
lastMessage: string | null;
|
|
16
|
+
pendingPermissions: PendingPermission[];
|
|
17
|
+
/** Usage the agent reports (its latest snapshot), when the provider reports it. */
|
|
18
|
+
usage?: import("./usage.js").AgentUsage | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface StepAgent {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
/** Waits for the current turn, at most timeoutMs. Returns early on permission requests. */
|
|
24
|
+
wait(timeoutMs: number): Promise<WaitOutcome>;
|
|
25
|
+
/** Starts a new turn in the same session. Call wait() afterwards. */
|
|
26
|
+
followUp(text: string): Promise<void>;
|
|
27
|
+
archive(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StartAgentInput {
|
|
31
|
+
role: AgentRole;
|
|
32
|
+
title: string;
|
|
33
|
+
prompt: string;
|
|
34
|
+
cwd: string;
|
|
35
|
+
labels: Record<string, string>;
|
|
36
|
+
/** Attach the step agent under this chat agent (shows in its Subagents track). */
|
|
37
|
+
parentAgentId: string | null;
|
|
38
|
+
/** Agent settings to launch with (the role's, or a review panel member's), Paseo profiles resolved. */
|
|
39
|
+
profile?: import("./config.js").AgentProfile;
|
|
40
|
+
/** Place the agent in this Paseo workspace (a worktree created for the requirement); still a subagent of parentAgentId. */
|
|
41
|
+
workspaceId?: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Where Paseo-managed services live: the chat's workspace, or the requirement's own worktree. */
|
|
45
|
+
export type ServiceTarget = { agentId: string } | { workspaceId: string };
|
|
46
|
+
|
|
47
|
+
export interface WorktreeRequest {
|
|
48
|
+
/** Checkout to cut the worktree from (the chat's directory). */
|
|
49
|
+
sourceRepo: string;
|
|
50
|
+
/** Branch or commit the new branch starts from. */
|
|
51
|
+
baseRef: string;
|
|
52
|
+
branchName: string;
|
|
53
|
+
title: string;
|
|
54
|
+
/** The chat; Paseo shows the workspace as created from it. */
|
|
55
|
+
callerAgentId: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A paseo.json service the pipeline runs through Paseo (supervised, own port per worktree). */
|
|
59
|
+
export interface ManagedService {
|
|
60
|
+
name: string;
|
|
61
|
+
/** Address to use: Paseo's proxy URL when there is one, otherwise http://127.0.0.1:<port>. */
|
|
62
|
+
url: string | null;
|
|
63
|
+
port: number | null;
|
|
64
|
+
running: boolean;
|
|
65
|
+
health: "healthy" | "unhealthy" | null;
|
|
66
|
+
exitCode: number | null;
|
|
67
|
+
/** Last lines of the service's terminal, when they could be read. */
|
|
68
|
+
output: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One subscription window of a provider (Paseo's provider usage), e.g. "5 小时" at 92%. */
|
|
72
|
+
export interface ProviderQuota {
|
|
73
|
+
provider: string;
|
|
74
|
+
displayName: string;
|
|
75
|
+
window: string;
|
|
76
|
+
usedPct: number | null;
|
|
77
|
+
resetsAt: string | null;
|
|
78
|
+
tone: "default" | "ok" | "warning" | "danger" | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A recent thing an agent did, in plain words, e.g. "运行命令:npm test". */
|
|
82
|
+
export interface ActivityItem {
|
|
83
|
+
at: string;
|
|
84
|
+
text: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ScheduledMessage {
|
|
88
|
+
agentId: string;
|
|
89
|
+
name: string;
|
|
90
|
+
prompt: string;
|
|
91
|
+
/** One run at this time; or `cron` for a recurring one. */
|
|
92
|
+
at?: Date;
|
|
93
|
+
cron?: string;
|
|
94
|
+
/** Recurring schedules stop by themselves at this time. */
|
|
95
|
+
expiresAt?: Date;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ScheduledAgent {
|
|
99
|
+
name: string;
|
|
100
|
+
prompt: string;
|
|
101
|
+
cron: string;
|
|
102
|
+
cwd: string;
|
|
103
|
+
profile: import("./config.js").AgentProfile;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface AgentRuntime {
|
|
107
|
+
/**
|
|
108
|
+
* Checks that the chat which launched the pipeline exists and works in `cwd`.
|
|
109
|
+
* Step agents are created under that chat, and Paseo gives a subagent its parent's
|
|
110
|
+
* working directory, so the two must match. Throws with a user-facing reason otherwise.
|
|
111
|
+
*/
|
|
112
|
+
verifyOrigin(agentId: string, cwd: string): Promise<void>;
|
|
113
|
+
startAgent(input: StartAgentInput): Promise<StepAgent>;
|
|
114
|
+
/** Delivers a message into an existing agent's conversation without interrupting it. */
|
|
115
|
+
notifyAgent(agentId: string, text: string): Promise<void>;
|
|
116
|
+
/** Interrupts the agent's current turn and gives it `text` instead (used to stop a step). */
|
|
117
|
+
interruptAgent(agentId: string, text: string): Promise<void>;
|
|
118
|
+
/** The daemon's agent profiles (Paseo settings → Agent profiles); null when they cannot be read. */
|
|
119
|
+
listPaseoProfiles(): Promise<import("./paseo-profiles.js").PaseoProfile[] | null>;
|
|
120
|
+
/**
|
|
121
|
+
* Names of the service scripts (paseo.json) of the workspace the chat works in. Null when
|
|
122
|
+
* Paseo cannot run them for us: no chat, an older daemon, or no services configured.
|
|
123
|
+
*/
|
|
124
|
+
listServices(target: ServiceTarget): Promise<string[] | null>;
|
|
125
|
+
/** (Re)starts the services so they run the current code, and waits until they are ready or failed. */
|
|
126
|
+
startServices(target: ServiceTarget, names: string[], readyTimeoutMs: number): Promise<ManagedService[]>;
|
|
127
|
+
stopServices(target: ServiceTarget, names: string[]): Promise<void>;
|
|
128
|
+
/** Creates a Paseo worktree workspace (paseo.json setup runs in the background). */
|
|
129
|
+
createWorktree(request: WorktreeRequest): Promise<{ workspaceId: string; directory: string }>;
|
|
130
|
+
/** Waits for the workspace's paseo.json setup; "none" when there is nothing to wait for or it cannot be read. */
|
|
131
|
+
waitForWorkspaceSetup(workspaceId: string, timeoutMs: number): Promise<{ status: "completed" | "failed" | "none" | "timeout"; error: string | null }>;
|
|
132
|
+
/** Subscription windows of the given providers; null when Paseo has no usage data. */
|
|
133
|
+
providerQuota(providers: string[]): Promise<ProviderQuota[] | null>;
|
|
134
|
+
/** The agent's latest actions, oldest first; null when they cannot be read. */
|
|
135
|
+
recentActivity(agentId: string, limit: number): Promise<ActivityItem[] | null>;
|
|
136
|
+
/** Which Paseo tools step agents get (Settings → Agents); null when unknown. */
|
|
137
|
+
capabilities(): Promise<{ paseoTools: boolean; browser: boolean } | null>;
|
|
138
|
+
/**
|
|
139
|
+
* Paseo schedule that sends `prompt` into an existing agent's conversation (the chat):
|
|
140
|
+
* once at `at`, or on `cron` (5 fields, local time zone). Returns the schedule ID, or
|
|
141
|
+
* null when this Paseo cannot schedule.
|
|
142
|
+
*/
|
|
143
|
+
scheduleMessage(input: ScheduledMessage): Promise<string | null>;
|
|
144
|
+
/** Paseo schedule that starts a fresh agent on `cron` (e.g. to run a command weekly). */
|
|
145
|
+
scheduleNewAgent(input: ScheduledAgent): Promise<string | null>;
|
|
146
|
+
cancelSchedule(id: string): Promise<void>;
|
|
147
|
+
close(): Promise<void>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface RuntimeFactoryInput {
|
|
151
|
+
config: import("./config.js").PipelineConfig;
|
|
152
|
+
log: (message: string) => void;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type RuntimeFactory = (input: RuntimeFactoryInput) => Promise<AgentRuntime>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Times for Paseo schedules: parsing what people type ("22:00", "明天 8:30", "+2h")
|
|
2
|
+
// and turning a moment into a one-shot cron expression in the local time zone.
|
|
3
|
+
|
|
4
|
+
export function localTimeZone(): string {
|
|
5
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** "M H D Mon *" for one moment (the schedule is limited to one run). */
|
|
9
|
+
export function cronAt(at: Date): string {
|
|
10
|
+
return `${at.getMinutes()} ${at.getHours()} ${at.getDate()} ${at.getMonth() + 1} *`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Every N minutes, as cron: minutes under an hour, otherwise whole hours. */
|
|
14
|
+
export function cronEvery(minutes: number): string {
|
|
15
|
+
if (minutes < 60) return `*/${Math.max(5, Math.round(minutes))} * * * *`;
|
|
16
|
+
return `0 */${Math.min(23, Math.max(1, Math.round(minutes / 60)))} * * *`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Accepts "22:00" (today, or tomorrow when already past), "明天 8:30" / "tomorrow 8:30",
|
|
21
|
+
* "2026-10-14 08:30", "+90m", "+2h". Returns null when it cannot be read.
|
|
22
|
+
*/
|
|
23
|
+
export function parseStartTime(text: string, now = new Date()): Date | null {
|
|
24
|
+
const t = text.trim();
|
|
25
|
+
const rel = /^\+\s*(\d+(?:\.\d+)?)\s*(m|min|分钟|h|小时)$/i.exec(t);
|
|
26
|
+
if (rel) {
|
|
27
|
+
const n = Number(rel[1]);
|
|
28
|
+
const minutes = /^(m|min|分钟)$/i.test(rel[2]) ? n : n * 60;
|
|
29
|
+
return new Date(now.getTime() + Math.round(minutes * 60_000));
|
|
30
|
+
}
|
|
31
|
+
const full = /^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{2})$/.exec(t);
|
|
32
|
+
if (full) {
|
|
33
|
+
const d = new Date(Number(full[1]), Number(full[2]) - 1, Number(full[3]), Number(full[4]), Number(full[5]));
|
|
34
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
35
|
+
}
|
|
36
|
+
const clock = /^(今天|明天|today|tomorrow)?\s*(\d{1,2})[::](\d{2})$/i.exec(t);
|
|
37
|
+
if (clock) {
|
|
38
|
+
const h = Number(clock[2]);
|
|
39
|
+
const m = Number(clock[3]);
|
|
40
|
+
if (h > 23 || m > 59) return null;
|
|
41
|
+
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m);
|
|
42
|
+
const tomorrow = /^(明天|tomorrow)$/i.test(clock[1] ?? "");
|
|
43
|
+
if (tomorrow || (!clock[1] && d.getTime() <= now.getTime())) d.setDate(d.getDate() + 1);
|
|
44
|
+
return d;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function describeTime(at: Date): string {
|
|
50
|
+
return at.toLocaleString("zh-CN", { hour12: false, month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
51
|
+
}
|