paseo-req-kit 0.0.0-stage → 1.8.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.
Files changed (144) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/LICENSE +21 -0
  3. package/README.md +146 -2
  4. package/SCOPE.md +11 -0
  5. package/bin/locate.mjs +46 -0
  6. package/bin/req-doctor.mjs +21 -0
  7. package/bin/req.mjs +179 -0
  8. package/bin/start-pipeline +21 -0
  9. package/bin/start-pipeline.mjs +22 -0
  10. package/config.example.json +132 -0
  11. package/contracts/README.md +18 -0
  12. package/contracts/feature-files.md +117 -0
  13. package/contracts/notifier-event.md +72 -0
  14. package/contracts/result-file.md +21 -0
  15. package/contracts/spec.md +36 -0
  16. package/contracts/state.md +49 -0
  17. package/contracts/verify.md +41 -0
  18. package/docs/adr/0001-step-agents-under-origin-chat.md +15 -0
  19. package/docs/adr/0002-no-directory-switch-in-chat.md +15 -0
  20. package/docs/adr/0003-script-engine-not-hub.md +15 -0
  21. package/docs/adr/0004-result-files.md +15 -0
  22. package/docs/adr/0005-no-default-branch-local-commits-only.md +15 -0
  23. package/docs/adr/0006-skill-install-targets.md +15 -0
  24. package/docs/adr/0007-own-interview-skill.md +15 -0
  25. package/docs/adr/0008-entries-do-not-call-entries.md +15 -0
  26. package/docs/adr/0009-versioned-install-layout.md +15 -0
  27. package/docs/adr/0010-generated-feature-overview.md +15 -0
  28. package/docs/adr/0011-resume-after-completion.md +15 -0
  29. package/docs/adr/0012-review-panel.md +18 -0
  30. package/docs/adr/0013-no-release-tooling.md +15 -0
  31. package/docs/adr/0014-notifier-slot.md +19 -0
  32. package/docs/adr/0015-incremental-continue.md +17 -0
  33. package/docs/adr/0016-upgrade-from-tags.md +19 -0
  34. package/docs/adr/0017-use-paseo-capabilities.md +18 -0
  35. package/docs/adr/0018-own-worktree-option.md +21 -0
  36. package/docs/adr/0019-parallel-orchestration.md +21 -0
  37. package/docs/adr/0020-scheduling-through-the-chat.md +21 -0
  38. package/docs/adr/0021-npm-version-manager.md +19 -0
  39. package/docs/adr/0022-develop-and-verify-phases.md +32 -0
  40. package/docs/adr/0023-types-gate-slices.md +54 -0
  41. package/docs/adr/0024-confirm-early-review-axes.md +27 -0
  42. package/docs/adr/0025-joint-requirements.md +27 -0
  43. package/docs/adr/README.md +29 -0
  44. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +96 -0
  45. package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +348 -0
  46. package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +85 -0
  47. 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 +356 -0
  48. package/engine/prompts/00-shared-context.md +104 -0
  49. package/engine/prompts/02-spec-review.md +46 -0
  50. package/engine/prompts/03-design.md +73 -0
  51. package/engine/prompts/03a-design-proposal.md +30 -0
  52. package/engine/prompts/04-design-review.md +73 -0
  53. package/engine/prompts/04b-design-revise.md +41 -0
  54. package/engine/prompts/05-implement.md +72 -0
  55. package/engine/prompts/06-code-review.md +70 -0
  56. package/engine/prompts/07-review-fix.md +41 -0
  57. package/engine/prompts/07b-handoff.md +45 -0
  58. package/engine/prompts/08-service-start.md +59 -0
  59. package/engine/prompts/09-verify.md +90 -0
  60. package/engine/prompts/10-defect-fix.md +41 -0
  61. package/engine/prompts/10b-reverify.md +43 -0
  62. package/engine/prompts/11-report.md +112 -0
  63. package/engine/prompts/browser-verify.md +13 -0
  64. package/engine/prompts/incremental.md +22 -0
  65. package/engine/prompts/joint.md +55 -0
  66. package/engine/prompts/panel-member.md +38 -0
  67. package/engine/prompts/paseo-services.md +9 -0
  68. package/engine/prompts/related-repos.md +7 -0
  69. package/engine/prompts/review-axis-spec.md +10 -0
  70. package/engine/prompts/review-axis-standards.md +11 -0
  71. package/engine/prompts/types/assessment.md +41 -0
  72. package/engine/prompts/types/defect.md +55 -0
  73. package/engine/prompts/types/refactor.md +39 -0
  74. package/engine/prompts/verify-deployed.md +14 -0
  75. package/engine/src/activity.ts +87 -0
  76. package/engine/src/cli-args.ts +217 -0
  77. package/engine/src/config.ts +619 -0
  78. package/engine/src/configure.ts +237 -0
  79. package/engine/src/control.ts +246 -0
  80. package/engine/src/daemon-check.ts +104 -0
  81. package/engine/src/daemon-version.ts +66 -0
  82. package/engine/src/digest.ts +142 -0
  83. package/engine/src/doctor.ts +462 -0
  84. package/engine/src/eval.ts +324 -0
  85. package/engine/src/features.ts +270 -0
  86. package/engine/src/gate.ts +157 -0
  87. package/engine/src/git.ts +30 -0
  88. package/engine/src/joint.ts +173 -0
  89. package/engine/src/launch.ts +405 -0
  90. package/engine/src/lessons.ts +72 -0
  91. package/engine/src/locations.ts +55 -0
  92. package/engine/src/main.ts +346 -0
  93. package/engine/src/messages.ts +394 -0
  94. package/engine/src/notifiers.ts +76 -0
  95. package/engine/src/notify-test.ts +101 -0
  96. package/engine/src/paseo-json.ts +73 -0
  97. package/engine/src/paseo-profiles.ts +133 -0
  98. package/engine/src/paseo-runtime.ts +437 -0
  99. package/engine/src/paths.ts +10 -0
  100. package/engine/src/pipeline.ts +1840 -0
  101. package/engine/src/playbooks.ts +46 -0
  102. package/engine/src/preflight.ts +337 -0
  103. package/engine/src/prompts.ts +72 -0
  104. package/engine/src/queue.ts +61 -0
  105. package/engine/src/runtime-loader.ts +14 -0
  106. package/engine/src/runtime.ts +155 -0
  107. package/engine/src/schedule-time.ts +51 -0
  108. package/engine/src/smoke-test.ts +202 -0
  109. package/engine/src/spec-review.ts +100 -0
  110. package/engine/src/state.ts +333 -0
  111. package/engine/src/steps.ts +266 -0
  112. package/engine/src/usage.ts +67 -0
  113. package/engine/src/worktree.ts +72 -0
  114. package/install.mjs +928 -0
  115. package/notifiers/im-template.mjs +60 -0
  116. package/package.json +67 -4
  117. package/principles/INDEX.md +11 -0
  118. package/skills/acceptance-criteria/SKILL.md +61 -0
  119. package/skills/principle-delete-before-add/SKILL.md +33 -0
  120. package/skills/principle-dont-block-on-humans/SKILL.md +35 -0
  121. package/skills/principle-fix-root-cause/SKILL.md +33 -0
  122. package/skills/principle-prove-it-works/SKILL.md +33 -0
  123. package/skills/principle-report-honestly/SKILL.md +33 -0
  124. package/skills/principle-speak-to-reader/SKILL.md +33 -0
  125. package/skills/principle-this-requirement-only/SKILL.md +33 -0
  126. package/skills/req/SKILL.md +101 -0
  127. package/skills/req/playbooks/accept.md +65 -0
  128. package/skills/req/playbooks/change.md +51 -0
  129. package/skills/req/playbooks/incident.md +55 -0
  130. package/skills/req/playbooks/new.md +94 -0
  131. package/skills/req/playbooks/small.md +48 -0
  132. package/skills/req/playbooks/status.md +41 -0
  133. package/skills/req/playbooks/takeover.md +51 -0
  134. package/skills/req/playbooks/verify.md +57 -0
  135. package/skills/req-doctor/SKILL.md +36 -0
  136. package/skills/req-retro/SKILL.md +81 -0
  137. package/skills/req-setup/SKILL.md +118 -0
  138. package/skills/req-setup/templates/paseo/copy-local-files.mjs +32 -0
  139. package/skills/req-setup/templates/paseo/with-port.mjs +36 -0
  140. package/skills/requirement-interview/SKILL.md +97 -0
  141. package/skills/requirement-kickoff/SKILL.md +17 -0
  142. package/skills/result-report/SKILL.md +34 -0
  143. package/skills/spec-writing/SKILL.md +55 -0
  144. package/skills/spec-writing/spec-template.md +109 -0
@@ -0,0 +1,157 @@
1
+ // The quality gate: project checks (lint, type check, tests, build) that the engine runs
2
+ // itself after the steps that change code, instead of trusting the agent's own report.
3
+ // Only checks that passed before the change and fail after it block; see ADR 0023.
4
+
5
+ import { execFileSync, spawn } from "node:child_process";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import type { CheckDef } from "./config.js";
9
+ import type { CheckOutcome } from "./state.js";
10
+
11
+ function git(repo: string, args: string[]): string | null {
12
+ try {
13
+ return execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ export function headCommit(repo: string): string | null {
20
+ return git(repo, ["rev-parse", "HEAD"])?.trim() || null;
21
+ }
22
+
23
+ /**
24
+ * Files changed since `base`: committed, staged, unstaged and untracked (repository-relative,
25
+ * "/" separators). Null when it cannot be told (not a git repository, unknown base).
26
+ */
27
+ export function changedFiles(repo: string, base: string | null): string[] | null {
28
+ if (!base) return null;
29
+ const diff = git(repo, ["diff", "--name-only", base]);
30
+ if (diff === null) return null;
31
+ const untracked = git(repo, ["ls-files", "--others", "--exclude-standard"]) ?? "";
32
+ const files = new Set([...diff.split("\n"), ...untracked.split("\n")].map((f) => f.trim()).filter(Boolean));
33
+ return [...files].sort();
34
+ }
35
+
36
+ /**
37
+ * Which of `files` changed since `commit` (committed or not); null when the commit is not
38
+ * in this repository. Used to tell whether acceptance tests committed first were changed later.
39
+ */
40
+ export function changedSince(repo: string, commit: string, files: string[]): string[] | null {
41
+ if (git(repo, ["rev-parse", "--verify", "--quiet", `${commit}^{commit}`]) === null) return null;
42
+ const out = git(repo, ["diff", "--name-only", commit, "--", ...files]);
43
+ if (out === null) return null;
44
+ return out.split("\n").map((f) => f.trim()).filter(Boolean).sort();
45
+ }
46
+
47
+ function globToRegExp(pattern: string): RegExp {
48
+ let re = "";
49
+ for (let i = 0; i < pattern.length; i++) {
50
+ const c = pattern[i];
51
+ if (c === "*" && pattern[i + 1] === "*") {
52
+ re += ".*";
53
+ i += 1;
54
+ if (pattern[i + 1] === "/") i += 1;
55
+ } else if (c === "*") re += "[^/]*";
56
+ else if (c === "?") re += "[^/]";
57
+ else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
58
+ }
59
+ return new RegExp(`^${re}$`);
60
+ }
61
+
62
+ /** Whether a repository-relative file falls under a path: "web/" (a directory), "src/a.ts", or a glob with *. */
63
+ export function matchesPath(file: string, pattern: string): boolean {
64
+ const p = pattern.trim().replace(/\\/g, "/").replace(/^\.\//, "");
65
+ if (p === "" || p === "." || p === "/") return true;
66
+ if (p.includes("*") || p.includes("?")) return globToRegExp(p).test(file);
67
+ if (p.endsWith("/")) return file.startsWith(p);
68
+ return file === p || file.startsWith(`${p}/`);
69
+ }
70
+
71
+ /** The paths a check watches; empty: the whole repository. */
72
+ export function checkPaths(check: CheckDef): string[] {
73
+ if (check.paths && check.paths.length > 0) return check.paths;
74
+ if (check.cwd && check.cwd !== ".") return [`${check.cwd.replace(/\\/g, "/").replace(/\/$/, "")}/`];
75
+ return [];
76
+ }
77
+
78
+ /** Whether a check applies to a change; with the changed files unknown, every check applies. */
79
+ export function checkApplies(check: CheckDef, files: string[] | null): boolean {
80
+ const paths = checkPaths(check);
81
+ if (paths.length === 0 || files === null) return true;
82
+ return files.some((f) => paths.some((p) => matchesPath(f, p)));
83
+ }
84
+
85
+ export interface CheckRun {
86
+ outcome: CheckOutcome;
87
+ /** Exit code, or null when it was stopped (timeout) or could not start. */
88
+ exitCode: number | null;
89
+ timedOut: boolean;
90
+ /** Combined output, trimmed to the end. */
91
+ output: string;
92
+ seconds: number;
93
+ }
94
+
95
+ const MAX_OUTPUT = 200_000;
96
+
97
+ /** Runs one check through the system shell, in the repository (or its cwd). Never throws. */
98
+ export function runCheck(check: CheckDef, repo: string, defaultTimeoutMinutes: number): Promise<CheckRun> {
99
+ const started = Date.now();
100
+ const cwd = path.join(repo, check.cwd ?? ".");
101
+ const timeoutMs = (check.timeoutMinutes ?? defaultTimeoutMinutes) * 60_000;
102
+ return new Promise((resolve) => {
103
+ let output = "";
104
+ const append = (chunk: Buffer) => {
105
+ output += chunk.toString("utf8");
106
+ if (output.length > MAX_OUTPUT * 2) output = output.slice(-MAX_OUTPUT);
107
+ };
108
+ const finish = (exitCode: number | null, timedOut: boolean, extra = "") => {
109
+ const text = `${output}${extra}`.slice(-MAX_OUTPUT);
110
+ resolve({ outcome: exitCode === 0 && !timedOut ? "pass" : "fail", exitCode, timedOut, output: text, seconds: Math.round((Date.now() - started) / 1000) });
111
+ };
112
+ if (!fs.existsSync(cwd)) {
113
+ finish(null, false, `检查的目录不存在:${cwd}`);
114
+ return;
115
+ }
116
+ let child;
117
+ try {
118
+ // CI=1: test runners stay non-interactive (no watch mode, no prompts).
119
+ child = spawn(check.command, { cwd, shell: true, env: { ...process.env, CI: process.env.CI ?? "1" }, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" });
120
+ } catch (error) {
121
+ finish(null, false, `无法启动:${(error as Error).message}`);
122
+ return;
123
+ }
124
+ let timedOut = false;
125
+ const timer = setTimeout(() => {
126
+ timedOut = true;
127
+ try {
128
+ // The whole process group: a test runner's own children too.
129
+ if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGKILL");
130
+ else child.kill("SIGKILL");
131
+ } catch {
132
+ // Already gone.
133
+ }
134
+ }, timeoutMs);
135
+ child.stdout?.on("data", append);
136
+ child.stderr?.on("data", append);
137
+ child.on("error", (error) => {
138
+ clearTimeout(timer);
139
+ finish(null, false, `\n无法启动:${error.message}`);
140
+ });
141
+ child.on("close", (code) => {
142
+ clearTimeout(timer);
143
+ finish(code, timedOut, timedOut ? `\n(超过 ${check.timeoutMinutes ?? defaultTimeoutMinutes} 分钟,已停止)` : "");
144
+ });
145
+ });
146
+ }
147
+
148
+ /** The last lines of a check's output, for a message to the agent. */
149
+ export function tail(text: string, lines = 60): string {
150
+ const all = text.trimEnd().split(/\r?\n/);
151
+ return (all.length > lines ? ["…", ...all.slice(-lines)] : all).join("\n");
152
+ }
153
+
154
+ /** A file name for a check's log. */
155
+ export function logName(name: string): string {
156
+ return `${name.replace(/[^\p{L}\p{N}._-]+/gu, "-").replace(/^-+|-+$/g, "") || "check"}.log`;
157
+ }
@@ -0,0 +1,30 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ export interface GitInfo {
4
+ isRepo: boolean;
5
+ /** Current branch; null when not a repository or HEAD is detached. */
6
+ branch: string | null;
7
+ detached: boolean;
8
+ /** Branches that must never receive the pipeline's commits. */
9
+ protectedBranches: string[];
10
+ }
11
+
12
+ function git(repo: string, args: string[]): string | null {
13
+ try {
14
+ return execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export function gitInfo(repo: string): GitInfo {
21
+ if (git(repo, ["rev-parse", "--is-inside-work-tree"]) !== "true") {
22
+ return { isRepo: false, branch: null, detached: false, protectedBranches: [] };
23
+ }
24
+ // symbolic-ref works before the first commit, unlike rev-parse --abbrev-ref HEAD.
25
+ const branch = git(repo, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
26
+ const protectedBranches = new Set(["main", "master"]);
27
+ const originHead = git(repo, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
28
+ if (originHead) protectedBranches.add(originHead.replace(/^origin\//, ""));
29
+ return { isRepo: true, branch, detached: branch === null, protectedBranches: [...protectedBranches] };
30
+ }
@@ -0,0 +1,173 @@
1
+ // Joint requirements (ADR 0025): one requirement that also changes other repositories
2
+ // (e.g. the backend of a frontend project). The run in the chat's repository coordinates:
3
+ // it designs the whole change and the interface contract, then each other repository gets
4
+ // a Paseo worktree on the requirement's branch where its own implementation, code review
5
+ // and fixes run (in parallel with this repository's); handoff, verification and the report
6
+ // stay here. Everything is committed locally only.
7
+
8
+ import { execFileSync } from "node:child_process";
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { type CheckDef, type RelatedRepo, resolveRelatedRepo } from "./config.js";
12
+ import type { AgentRuntime } from "./runtime.js";
13
+ import { ensurePipelineDir, hashFile, type JointRepo, type PipelineState } from "./state.js";
14
+
15
+ /** At most this many other repositories in one requirement (three in all). */
16
+ export const MAX_JOINT = 2;
17
+
18
+ function git(cwd: string, ...args: string[]): string {
19
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
20
+ }
21
+
22
+ /** Where a step runs: this repository (no target) or one of the joint repositories. */
23
+ export interface StepTarget {
24
+ name: string;
25
+ slug: string;
26
+ repo: string;
27
+ featureDir: string;
28
+ workspaceId: string | null;
29
+ branch: string | null;
30
+ joint: JointRepo;
31
+ }
32
+
33
+ /** An ASCII suffix for step keys, from the repository's directory name. */
34
+ export function slugFor(dir: string, taken: string[]): string {
35
+ const base = path.basename(dir).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "repo";
36
+ let slug = base;
37
+ for (let i = 2; taken.includes(slug); i++) slug = `${base}-${i}`;
38
+ return slug;
39
+ }
40
+
41
+ /** Problems with the spec's "联合开发" names for this configuration, or null. */
42
+ export function jointProblem(names: string[], related: RelatedRepo[], projectDir: string): string | null {
43
+ if (names.length === 0) return null;
44
+ if (names.length > MAX_JOINT) return `联合开发最多再涉及 ${MAX_JOINT} 个仓库(一共 ${MAX_JOINT + 1} 个),需求说明里写了 ${names.length} 个:${names.join("、")}`;
45
+ for (const name of names) {
46
+ const r = related.find((x) => x.name === name);
47
+ if (!r) {
48
+ return `需求说明的"联合开发"中有「${name}」,但项目配置的 relatedRepos 里没有登记这个仓库${related.length > 0 ? `(已登记:${related.map((x) => x.name).join("、")})` : ""}。请在 .requirement-pipeline.json 中登记,或改正需求说明`;
49
+ }
50
+ const dir = resolveRelatedRepo(r, projectDir);
51
+ if (!fs.existsSync(dir)) return `联合开发的仓库「${name}」不在这台机器上:${dir}`;
52
+ try {
53
+ git(dir, "rev-parse", "--git-dir");
54
+ } catch {
55
+ return `联合开发的仓库「${name}」不是 git 仓库:${dir}`;
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /** The joint repositories this run works in: those the spec names now, in that order. */
62
+ export function activeJoint(state: Pick<PipelineState, "joint">, names: string[]): JointRepo[] {
63
+ return names.map((n) => (state.joint ?? []).find((j) => j.name === n)).filter((j): j is JointRepo => j !== undefined);
64
+ }
65
+
66
+ /**
67
+ * Creates the worktree of each named repository that does not have one yet, on the branch
68
+ * feature/<requirement>, cut from what that repository has checked out. Returns the entries.
69
+ */
70
+ export async function ensureJointRepos(input: {
71
+ runtime: Pick<AgentRuntime, "createWorktree">;
72
+ state: PipelineState;
73
+ featureDir: string;
74
+ names: string[];
75
+ related: RelatedRepo[];
76
+ projectDir: string;
77
+ title: string;
78
+ log: (message: string) => void;
79
+ }): Promise<JointRepo[]> {
80
+ const { state } = input;
81
+ state.joint ??= [];
82
+ for (const name of input.names) {
83
+ if (state.joint.some((j) => j.name === name)) continue;
84
+ const r = input.related.find((x) => x.name === name);
85
+ if (!r) throw new Error(`联合开发的仓库「${name}」没有在 relatedRepos 中登记`);
86
+ const sourceRepo = fs.realpathSync(resolveRelatedRepo(r, input.projectDir));
87
+ const branch = `feature/${path.basename(input.featureDir)}`;
88
+ let exists = true;
89
+ try {
90
+ git(sourceRepo, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`);
91
+ } catch {
92
+ exists = false;
93
+ }
94
+ if (exists) {
95
+ throw new Error(`联合开发的仓库「${name}」(${sourceRepo})里已经有分支 ${branch}。可能是之前开始过这个需求:请在那个仓库确认后删除或改名这个分支,再继续`);
96
+ }
97
+ const head = git(sourceRepo, "rev-parse", "--abbrev-ref", "HEAD");
98
+ const baseRef = head === "HEAD" ? git(sourceRepo, "rev-parse", "HEAD") : head;
99
+ const created = await input.runtime.createWorktree({
100
+ sourceRepo,
101
+ baseRef,
102
+ branchName: branch,
103
+ title: `${input.title}(${name})`,
104
+ callerAgentId: state.originAgentId,
105
+ });
106
+ const repo = fs.realpathSync(created.directory);
107
+ ensurePipelineDir(repo, input.featureDir);
108
+ const entry: JointRepo = {
109
+ name,
110
+ slug: slugFor(sourceRepo, state.joint.map((j) => j.slug)),
111
+ sourceRepo,
112
+ repo,
113
+ workspaceId: created.workspaceId,
114
+ branch,
115
+ specHash: null,
116
+ };
117
+ state.joint.push(entry);
118
+ input.log(`联合开发:为「${name}」新建了工作区 ${repo}(分支 ${branch},从 ${baseRef} 切出)`);
119
+ }
120
+ return activeJoint(state, input.names);
121
+ }
122
+
123
+ /**
124
+ * Copies the requirement's documents (spec, design, contract, preview) from this repository's
125
+ * feature directory into each joint repository's, and commits them there when they changed,
126
+ * so whoever reviews and merges that repository reads them next to its code.
127
+ */
128
+ export function syncJointDocs(entries: JointRepo[], mainRepo: string, featureDir: string, files: string[], title: string, log: (message: string) => void): void {
129
+ const from = path.join(mainRepo, featureDir);
130
+ for (const entry of entries) {
131
+ const to = path.join(entry.repo, featureDir);
132
+ fs.mkdirSync(to, { recursive: true });
133
+ ensurePipelineDir(entry.repo, featureDir);
134
+ const copied: string[] = [];
135
+ for (const file of files) {
136
+ const src = path.join(from, file);
137
+ if (!fs.existsSync(src)) continue;
138
+ fs.cpSync(src, path.join(to, file), { recursive: true });
139
+ copied.push(path.join(featureDir, file));
140
+ }
141
+ const spec = path.join(from, "spec.md");
142
+ if (fs.existsSync(spec)) entry.specHash = hashFile(spec);
143
+ if (copied.length === 0) continue;
144
+ try {
145
+ git(entry.repo, "add", "--", ...copied);
146
+ if (git(entry.repo, "diff", "--cached", "--name-only", "--", ...copied)) {
147
+ git(entry.repo, "commit", "-q", "-m", `docs(req): ${title} 的需求说明和方案(同步自主仓库)`, "--", ...copied);
148
+ log(`联合开发:已把 ${files.filter((f) => fs.existsSync(path.join(from, f))).join("、")} 同步到「${entry.name}」并提交`);
149
+ }
150
+ } catch (error) {
151
+ const stderr = (error as { stderr?: Buffer | string }).stderr?.toString().trim();
152
+ log(`联合开发:同步到「${entry.name}」后提交失败(${stderr || (error as Error).message}),文件已复制,步骤照常可以读到`);
153
+ }
154
+ }
155
+ }
156
+
157
+ /** The quality-gate checks a joint repository defines in its own project configuration. */
158
+ export function loadProjectChecks(repo: string, log: (message: string) => void): CheckDef[] {
159
+ const file = path.join(repo, ".requirement-pipeline.json");
160
+ if (!fs.existsSync(file)) return [];
161
+ try {
162
+ const raw = JSON.parse(fs.readFileSync(file, "utf8")) as { checks?: unknown };
163
+ if (!Array.isArray(raw.checks)) return [];
164
+ const checks = raw.checks.filter(
165
+ (c): c is CheckDef => typeof c === "object" && c !== null && typeof (c as CheckDef).name === "string" && typeof (c as CheckDef).command === "string",
166
+ );
167
+ if (checks.length !== raw.checks.length) log(`${file} 中有写法不对的检查,已跳过`);
168
+ return checks;
169
+ } catch (error) {
170
+ log(`读不了 ${file}:${(error as Error).message},这个仓库不运行质量门`);
171
+ return [];
172
+ }
173
+ }