supipowers 2.1.0 → 2.2.0
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/package.json +1 -1
- package/src/bootstrap.ts +3 -0
- package/src/commands/optimize-context.ts +153 -16
- package/src/commands/runbook.ts +511 -0
- package/src/context/rule-renderer.ts +274 -2
- package/src/context/runbook-extension-template.ts +193 -0
- package/src/context/startup-check.ts +197 -2
- package/src/context/startup-optimizer.ts +133 -10
- package/src/harness/command.ts +98 -6
- package/src/harness/git-verification.ts +515 -0
- package/src/harness/git-verify-qa.ts +406 -0
- package/src/harness/pipeline.ts +17 -8
- package/src/harness/stages/implement-apply.ts +61 -4
- package/src/harness/stages/validate.ts +108 -0
- package/src/types.ts +40 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive Git topology + branch-protection sub-step for `/supi:harness`.
|
|
3
|
+
*
|
|
4
|
+
* Pure-ish entry point: takes an `ExecFn`, a minimal UI interface, and a session dir, and
|
|
5
|
+
* returns a populated `HarnessCiGitConfig` (or null when the user opts out). All side
|
|
6
|
+
* effects — branch creation, ruleset POST, manual-instructions doc — are routed through
|
|
7
|
+
* the dependencies so the harness command layer can drive it without changing.
|
|
8
|
+
*
|
|
9
|
+
* Why split this out of `src/harness/command.ts`? Two reasons:
|
|
10
|
+
* 1. Testability. The command file is 1100+ LOC and pulls in the full platform/agent
|
|
11
|
+
* stack; we want a tight Q&A test surface that operates on a fake UI + scripted
|
|
12
|
+
* `exec` results.
|
|
13
|
+
* 2. Separation of concerns. The command layer owns "when do we ask?", this module owns
|
|
14
|
+
* "what do we ask, and what do we do with the answers?".
|
|
15
|
+
*
|
|
16
|
+
* Decision tree (matches the user's spec):
|
|
17
|
+
* 1. Detect topology (default branch + dev candidates).
|
|
18
|
+
* 2. If default is `main`/`master`:
|
|
19
|
+
* a. "Do you have a development branch?"
|
|
20
|
+
* - Yes → "Which one?" (existing candidates or custom).
|
|
21
|
+
* - No → "Do you want one?"
|
|
22
|
+
* - Yes → "Name?" → "Create new from main, or promote existing?"
|
|
23
|
+
* - No → record devBranch=null, no enforcement.
|
|
24
|
+
* 3. If default is *not* main/master, treat it as already-dev and ask the user to
|
|
25
|
+
* confirm + pick a separate "main" branch from the listed remotes.
|
|
26
|
+
* 4. Optionally apply protections via gh; render manual-instructions doc on any
|
|
27
|
+
* skipped/failed protection step.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import * as fs from "node:fs";
|
|
31
|
+
import * as path from "node:path";
|
|
32
|
+
|
|
33
|
+
import type {
|
|
34
|
+
HarnessCiGitConfig,
|
|
35
|
+
HarnessCiGitFinding,
|
|
36
|
+
} from "../types.js";
|
|
37
|
+
import {
|
|
38
|
+
applyMainProtectionRuleset,
|
|
39
|
+
createBranchFromRef,
|
|
40
|
+
detectGitTopology,
|
|
41
|
+
isSafeBranchName,
|
|
42
|
+
renderManualInstructions,
|
|
43
|
+
type ExecFn,
|
|
44
|
+
type GhExecOutcome,
|
|
45
|
+
} from "./git-verification.js";
|
|
46
|
+
|
|
47
|
+
export interface GitVerifyQaUi {
|
|
48
|
+
select: (title: string, options: string[]) => Promise<string | null>;
|
|
49
|
+
input: (label: string) => Promise<string | null>;
|
|
50
|
+
notify: (message: string, level?: "info" | "warning" | "error") => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface GitVerifyQaInput {
|
|
54
|
+
exec: ExecFn;
|
|
55
|
+
cwd: string;
|
|
56
|
+
ui: GitVerifyQaUi;
|
|
57
|
+
/** Absolute path to the harness session directory where manual instructions land. */
|
|
58
|
+
sessionDir: string;
|
|
59
|
+
/** Clock injection for deterministic tests. */
|
|
60
|
+
now?: () => string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const TOP_LEVEL_RUN = "Run verification";
|
|
64
|
+
const TOP_LEVEL_SKIP = "Skip";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Capture-side validation for branch names that flow into the persisted design spec.
|
|
68
|
+
* Any value that reaches `HarnessCiGitConfig.{mainBranch,devBranch}` is rendered into
|
|
69
|
+
* the GitHub Actions workflow (single-quoted YAML expression and double-quoted shell
|
|
70
|
+
* line). We accept only the strict subset defined by `isSafeBranchName` so the render
|
|
71
|
+
* path stays escape-free and an injected branch name cannot break the workflow.
|
|
72
|
+
*
|
|
73
|
+
* Returns the trimmed name on success; pushes a finding and returns `null` otherwise.
|
|
74
|
+
*/
|
|
75
|
+
function captureBranchName(
|
|
76
|
+
raw: string | null | undefined,
|
|
77
|
+
role: "main" | "dev",
|
|
78
|
+
findings: HarnessCiGitFinding[],
|
|
79
|
+
): string | null {
|
|
80
|
+
const trimmed = raw?.trim() ?? "";
|
|
81
|
+
if (trimmed.length === 0) return null;
|
|
82
|
+
if (!isSafeBranchName(trimmed)) {
|
|
83
|
+
findings.push({
|
|
84
|
+
severity: "warning",
|
|
85
|
+
message: `Rejected unsafe ${role} branch name: ${JSON.stringify(trimmed)}`,
|
|
86
|
+
remediation:
|
|
87
|
+
"Branch names must match [A-Za-z0-9._/-]+ (no whitespace, quotes, or shell metacharacters). " +
|
|
88
|
+
"Re-run /supi:harness with a sanitized name.",
|
|
89
|
+
});
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return trimmed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Drive the interactive Git verification flow.
|
|
97
|
+
*
|
|
98
|
+
* Returns:
|
|
99
|
+
* - `null` when the user opts out of running verification entirely.
|
|
100
|
+
* - A populated `HarnessCiGitConfig` otherwise. Even when sub-steps fail (gh missing,
|
|
101
|
+
* branch creation rejected by the remote), we return a config so the design spec
|
|
102
|
+
* captures the user's intent — the failures land in `verification.findings`.
|
|
103
|
+
*/
|
|
104
|
+
export async function runGitVerificationQa(
|
|
105
|
+
input: GitVerifyQaInput,
|
|
106
|
+
): Promise<HarnessCiGitConfig | null> {
|
|
107
|
+
const now = input.now ?? (() => new Date().toISOString());
|
|
108
|
+
|
|
109
|
+
const topLevel = await input.ui.select(
|
|
110
|
+
"Run Git branching verification now? (checks default branch, optional dev branch, and PR-source restrictions)",
|
|
111
|
+
[TOP_LEVEL_RUN, TOP_LEVEL_SKIP],
|
|
112
|
+
);
|
|
113
|
+
if (topLevel !== TOP_LEVEL_RUN) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const topology = await detectGitTopology(input.exec, input.cwd);
|
|
118
|
+
input.ui.notify(
|
|
119
|
+
`Detected default branch: ${topology.mainBranch}` +
|
|
120
|
+
(topology.devBranchCandidates.length > 0
|
|
121
|
+
? ` — dev candidates: ${topology.devBranchCandidates.join(", ")}`
|
|
122
|
+
: ""),
|
|
123
|
+
"info",
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const findings: HarnessCiGitFinding[] = [];
|
|
127
|
+
const appliedProtections: string[] = [];
|
|
128
|
+
|
|
129
|
+
let mainBranch = topology.mainBranch;
|
|
130
|
+
let devBranch: string | null = null;
|
|
131
|
+
let enforceMainFromDevOnly = false;
|
|
132
|
+
|
|
133
|
+
if (topology.defaultIsMainOrMaster) {
|
|
134
|
+
const decision = await resolveDevBranchWhenDefaultIsMain(input, topology, findings, appliedProtections);
|
|
135
|
+
devBranch = decision.devBranch;
|
|
136
|
+
enforceMainFromDevOnly = decision.enforceMainFromDevOnly;
|
|
137
|
+
} else {
|
|
138
|
+
const decision = await resolveDevBranchWhenDefaultIsAlreadyDev(input, topology, findings);
|
|
139
|
+
if (decision.mainBranch) mainBranch = decision.mainBranch;
|
|
140
|
+
devBranch = decision.devBranch;
|
|
141
|
+
enforceMainFromDevOnly = decision.enforceMainFromDevOnly;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Attempt the server-side ruleset opportunistically when enforcement is on.
|
|
145
|
+
if (enforceMainFromDevOnly && devBranch) {
|
|
146
|
+
const outcome = await applyMainProtectionRuleset(input.exec, input.cwd, {
|
|
147
|
+
mainBranch,
|
|
148
|
+
devBranch,
|
|
149
|
+
});
|
|
150
|
+
foldRulesetOutcome(outcome, findings, appliedProtections);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// CI-side guardrail always applies when enforcement is on — recorded so the validate
|
|
154
|
+
// stage can confirm the rendered workflow contains the verify-pr-source job.
|
|
155
|
+
if (enforceMainFromDevOnly && devBranch) {
|
|
156
|
+
appliedProtections.push("ci-guardrail");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Manual-instructions doc lives at <session>/git-verification.md whenever any
|
|
160
|
+
// protection step skipped or failed — gives the user a copy-pasteable fallback.
|
|
161
|
+
const ghAvailable = appliedProtections.includes("ruleset");
|
|
162
|
+
const needsManualDoc =
|
|
163
|
+
enforceMainFromDevOnly && devBranch !== null && !ghAvailable;
|
|
164
|
+
|
|
165
|
+
let manualInstructionsPath: string | null = null;
|
|
166
|
+
if (needsManualDoc) {
|
|
167
|
+
const md = renderManualInstructions({
|
|
168
|
+
mainBranch,
|
|
169
|
+
devBranch,
|
|
170
|
+
enforceMainFromDevOnly,
|
|
171
|
+
ghAvailable,
|
|
172
|
+
});
|
|
173
|
+
const rel = "git-verification.md";
|
|
174
|
+
fs.mkdirSync(input.sessionDir, { recursive: true });
|
|
175
|
+
fs.writeFileSync(path.join(input.sessionDir, rel), md, "utf8");
|
|
176
|
+
manualInstructionsPath = rel;
|
|
177
|
+
input.ui.notify(
|
|
178
|
+
`Wrote manual Git verification steps to ${rel}.`,
|
|
179
|
+
"info",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
mainBranch,
|
|
185
|
+
devBranch,
|
|
186
|
+
enforceMainFromDevOnly,
|
|
187
|
+
verification: {
|
|
188
|
+
checkedAt: now(),
|
|
189
|
+
appliedProtections,
|
|
190
|
+
findings,
|
|
191
|
+
manualInstructionsPath,
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Decision sub-flows
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
interface DevDecision {
|
|
201
|
+
devBranch: string | null;
|
|
202
|
+
enforceMainFromDevOnly: boolean;
|
|
203
|
+
/** Set by the "default is already dev" path; left undefined when the caller keeps the detected main. */
|
|
204
|
+
mainBranch?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function resolveDevBranchWhenDefaultIsMain(
|
|
208
|
+
input: GitVerifyQaInput,
|
|
209
|
+
topology: Awaited<ReturnType<typeof detectGitTopology>>,
|
|
210
|
+
findings: HarnessCiGitFinding[],
|
|
211
|
+
appliedProtections: string[],
|
|
212
|
+
): Promise<DevDecision> {
|
|
213
|
+
const hasOptions = topology.devBranchCandidates.length > 0;
|
|
214
|
+
const haveDevPrompt = hasOptions
|
|
215
|
+
? "Yes — use " + topology.devBranchCandidates[0]
|
|
216
|
+
: "Yes, I have one";
|
|
217
|
+
const decision = await input.ui.select(
|
|
218
|
+
"Do you have a development branch?",
|
|
219
|
+
[haveDevPrompt, "No, I don't have one"],
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
if (decision && decision.startsWith("Yes")) {
|
|
223
|
+
if (hasOptions) {
|
|
224
|
+
const picked = topology.devBranchCandidates[0];
|
|
225
|
+
const safe = captureBranchName(picked, "dev", findings);
|
|
226
|
+
if (safe) return { devBranch: safe, enforceMainFromDevOnly: true };
|
|
227
|
+
return { devBranch: null, enforceMainFromDevOnly: false };
|
|
228
|
+
}
|
|
229
|
+
const safe = captureBranchName(
|
|
230
|
+
await input.ui.input("What is your development branch?"),
|
|
231
|
+
"dev",
|
|
232
|
+
findings,
|
|
233
|
+
);
|
|
234
|
+
if (safe) return { devBranch: safe, enforceMainFromDevOnly: true };
|
|
235
|
+
findings.push({
|
|
236
|
+
severity: "warning",
|
|
237
|
+
message: "User claimed to have a dev branch but provided no usable name.",
|
|
238
|
+
remediation: "Re-run /supi:harness and provide a valid branch name.",
|
|
239
|
+
});
|
|
240
|
+
return { devBranch: null, enforceMainFromDevOnly: false };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// No existing dev branch — ask if they want one.
|
|
244
|
+
const wantsOne = await input.ui.select(
|
|
245
|
+
"Do you want a dedicated development branch?",
|
|
246
|
+
["Yes — create one", "No, run CI on main only"],
|
|
247
|
+
);
|
|
248
|
+
if (!wantsOne || !wantsOne.startsWith("Yes")) {
|
|
249
|
+
findings.push({
|
|
250
|
+
severity: "info",
|
|
251
|
+
message: "User opted out of a dedicated dev branch.",
|
|
252
|
+
remediation: "Re-run /supi:harness if you change your mind.",
|
|
253
|
+
});
|
|
254
|
+
return { devBranch: null, enforceMainFromDevOnly: false };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const rawName = (await input.ui.input("What name? (default: dev)"))?.trim() || "dev";
|
|
258
|
+
const name = captureBranchName(rawName, "dev", findings);
|
|
259
|
+
if (!name) {
|
|
260
|
+
return { devBranch: null, enforceMainFromDevOnly: false };
|
|
261
|
+
}
|
|
262
|
+
const action = await input.ui.select(
|
|
263
|
+
`Create \`${name}\` from \`${topology.mainBranch}\`, or promote an existing branch?`,
|
|
264
|
+
[
|
|
265
|
+
"Create new branch from main",
|
|
266
|
+
...(topology.allBranches.length > 0 ? ["Promote an existing branch"] : []),
|
|
267
|
+
],
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
if (action === "Promote an existing branch") {
|
|
271
|
+
const existingPick = await input.ui.select(
|
|
272
|
+
"Pick the branch to promote:",
|
|
273
|
+
topology.allBranches.filter((b) => b !== topology.mainBranch),
|
|
274
|
+
);
|
|
275
|
+
const safePick = captureBranchName(existingPick, "dev", findings);
|
|
276
|
+
if (safePick) {
|
|
277
|
+
return { devBranch: safePick, enforceMainFromDevOnly: true };
|
|
278
|
+
}
|
|
279
|
+
findings.push({
|
|
280
|
+
severity: "warning",
|
|
281
|
+
message: "No branch picked for promotion.",
|
|
282
|
+
remediation: "Re-run /supi:harness to finish wiring the dev branch.",
|
|
283
|
+
});
|
|
284
|
+
return { devBranch: null, enforceMainFromDevOnly: false };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Create new branch from main.
|
|
288
|
+
const outcome = await createBranchFromRef(
|
|
289
|
+
input.exec,
|
|
290
|
+
input.cwd,
|
|
291
|
+
name,
|
|
292
|
+
`origin/${topology.mainBranch}`,
|
|
293
|
+
);
|
|
294
|
+
if (outcome.kind === "created" || outcome.kind === "already-exists") {
|
|
295
|
+
appliedProtections.push("branch-created");
|
|
296
|
+
if (outcome.kind === "already-exists") {
|
|
297
|
+
findings.push({
|
|
298
|
+
severity: "info",
|
|
299
|
+
message: `Branch \`${name}\` already exists — reusing.`,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return { devBranch: name, enforceMainFromDevOnly: true };
|
|
303
|
+
}
|
|
304
|
+
findings.push({
|
|
305
|
+
severity: "error",
|
|
306
|
+
message: `Failed to create branch \`${name}\`: ${outcome.reason}`,
|
|
307
|
+
remediation: `Create the branch manually with: git switch -c ${name} origin/${topology.mainBranch} && git push -u origin ${name}`,
|
|
308
|
+
});
|
|
309
|
+
// Still record the user's intent so the design spec captures it; protections will be
|
|
310
|
+
// rejected by validate.
|
|
311
|
+
return { devBranch: name, enforceMainFromDevOnly: true };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function resolveDevBranchWhenDefaultIsAlreadyDev(
|
|
315
|
+
input: GitVerifyQaInput,
|
|
316
|
+
topology: Awaited<ReturnType<typeof detectGitTopology>>,
|
|
317
|
+
findings: HarnessCiGitFinding[],
|
|
318
|
+
): Promise<DevDecision> {
|
|
319
|
+
const otherBranches = topology.allBranches.filter((b) => b !== topology.mainBranch);
|
|
320
|
+
const pick = await input.ui.select(
|
|
321
|
+
`\`${topology.mainBranch}\` looks like a development branch. Pick the *dev* branch the harness should target:`,
|
|
322
|
+
[topology.mainBranch, ...otherBranches],
|
|
323
|
+
);
|
|
324
|
+
const devBranch = captureBranchName(pick ?? topology.mainBranch, "dev", findings);
|
|
325
|
+
if (!devBranch) {
|
|
326
|
+
return { devBranch: null, enforceMainFromDevOnly: false };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Ask which branch is the protected main.
|
|
330
|
+
const mainCandidates = topology.allBranches.filter(
|
|
331
|
+
(b) => b === "main" || b === "master",
|
|
332
|
+
);
|
|
333
|
+
let mainBranch: string | null = mainCandidates[0] ?? "main";
|
|
334
|
+
if (mainCandidates.length === 0) {
|
|
335
|
+
const provided = await input.ui.input("What is your main/master branch?");
|
|
336
|
+
if (provided && provided.trim().length > 0) {
|
|
337
|
+
mainBranch = captureBranchName(provided, "main", findings);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (!mainBranch) {
|
|
341
|
+
return { devBranch, enforceMainFromDevOnly: false };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (mainBranch === devBranch) {
|
|
345
|
+
findings.push({
|
|
346
|
+
severity: "warning",
|
|
347
|
+
message: "Main branch and dev branch are the same; PR-source restriction will be disabled.",
|
|
348
|
+
remediation: "Re-run /supi:harness and pick distinct branches.",
|
|
349
|
+
});
|
|
350
|
+
return { devBranch, mainBranch, enforceMainFromDevOnly: false };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return { devBranch, mainBranch, enforceMainFromDevOnly: true };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function foldRulesetOutcome(
|
|
357
|
+
outcome: GhExecOutcome,
|
|
358
|
+
findings: HarnessCiGitFinding[],
|
|
359
|
+
appliedProtections: string[],
|
|
360
|
+
): void {
|
|
361
|
+
switch (outcome.kind) {
|
|
362
|
+
case "applied":
|
|
363
|
+
appliedProtections.push("ruleset");
|
|
364
|
+
return;
|
|
365
|
+
case "skipped":
|
|
366
|
+
switch (outcome.reason) {
|
|
367
|
+
case "no-cli":
|
|
368
|
+
findings.push({
|
|
369
|
+
severity: "warning",
|
|
370
|
+
message: "`gh` CLI is not installed; could not apply server-side ruleset.",
|
|
371
|
+
remediation: "Install gh (https://cli.github.com/) and re-run /supi:harness, or follow the manual steps.",
|
|
372
|
+
});
|
|
373
|
+
return;
|
|
374
|
+
case "no-auth":
|
|
375
|
+
findings.push({
|
|
376
|
+
severity: "warning",
|
|
377
|
+
message: "`gh` is not authenticated; could not apply server-side ruleset.",
|
|
378
|
+
remediation: "Run `gh auth login --scopes admin:repo` and re-run /supi:harness.",
|
|
379
|
+
});
|
|
380
|
+
return;
|
|
381
|
+
case "no-permission":
|
|
382
|
+
findings.push({
|
|
383
|
+
severity: "warning",
|
|
384
|
+
message: "`gh` lacks `admin:repo` scope; could not apply server-side ruleset.",
|
|
385
|
+
remediation: "Run `gh auth refresh -s admin:repo` and re-run /supi:harness, or follow the manual steps.",
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
388
|
+
case "no-repo":
|
|
389
|
+
findings.push({
|
|
390
|
+
severity: "info",
|
|
391
|
+
message: "Could not detect GitHub repo from `gh repo view`; skipping ruleset.",
|
|
392
|
+
remediation: "Confirm the repository is connected to GitHub (gh repo view should print owner/repo).",
|
|
393
|
+
});
|
|
394
|
+
return;
|
|
395
|
+
case "no-dev-branch":
|
|
396
|
+
return; // Should be unreachable here — we gate on devBranch above.
|
|
397
|
+
}
|
|
398
|
+
case "failed":
|
|
399
|
+
findings.push({
|
|
400
|
+
severity: "error",
|
|
401
|
+
message: `Ruleset API call failed: ${outcome.reason}`,
|
|
402
|
+
remediation: "Inspect the failure and apply the ruleset manually via Settings → Rules → Rulesets.",
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
}
|
package/src/harness/pipeline.ts
CHANGED
|
@@ -151,6 +151,12 @@ export interface PipelineDriverInput {
|
|
|
151
151
|
* complete). Used by per-stage subcommands.
|
|
152
152
|
*/
|
|
153
153
|
startStage?: HarnessStage;
|
|
154
|
+
/**
|
|
155
|
+
* Stages listed here bypass their `isComplete` short-circuit. Used by the harden
|
|
156
|
+
* path after mutating the persisted design spec to force a fresh re-render even when
|
|
157
|
+
* a prior successful run left the stage's completion artifact on disk.
|
|
158
|
+
*/
|
|
159
|
+
forceStages?: ReadonlySet<HarnessStage>;
|
|
154
160
|
/** Hard cap on stage iterations. */
|
|
155
161
|
safetyLimit?: number;
|
|
156
162
|
/** Optional callback for progress events (wire up a progress widget). */
|
|
@@ -269,14 +275,17 @@ export async function runHarnessPipelineUntilGate(
|
|
|
269
275
|
const stageInputs = ensureStageInputs(input, stage);
|
|
270
276
|
const runner = buildHarnessRunner(stage, stageInputs);
|
|
271
277
|
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
278
|
+
const forced = input.forceStages?.has(stage) ?? false;
|
|
279
|
+
const isComplete = forced
|
|
280
|
+
? false
|
|
281
|
+
: await runner.isComplete({
|
|
282
|
+
platform: input.platform,
|
|
283
|
+
paths: input.paths,
|
|
284
|
+
cwd: input.cwd,
|
|
285
|
+
sessionId: input.sessionId,
|
|
286
|
+
modelConfig: input.modelConfig,
|
|
287
|
+
gateMode: input.gates,
|
|
288
|
+
});
|
|
280
289
|
|
|
281
290
|
if (isComplete) {
|
|
282
291
|
input.onProgress?.({ type: "stage-skipped", stage });
|
|
@@ -470,11 +470,32 @@ function applyCiWorkflow(
|
|
|
470
470
|
);
|
|
471
471
|
}
|
|
472
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Literal Actions expression `${{ github.event.pull_request.head.ref }}`. Hoisted as a
|
|
475
|
+
* named constant because the inline `${"${{ ... }}"}` form (TS template literal escaping
|
|
476
|
+
* a YAML expression) reads as a typo at the call site.
|
|
477
|
+
*/
|
|
478
|
+
const PR_HEAD_REF_EXPR = "${{ github.event.pull_request.head.ref }}";
|
|
479
|
+
|
|
473
480
|
function renderGithubActionsWorkflow(spec: HarnessDesignSpec): string {
|
|
474
481
|
const trigger = spec.ci.trigger;
|
|
482
|
+
// Defense in depth: when the PR-source guardrail is on, force `mainBranch` into the
|
|
483
|
+
// trigger's branch list at render time even if the persisted spec was hand-edited and
|
|
484
|
+
// omits it. Without this, the `verify-pr-source` job below is dead code on PRs into
|
|
485
|
+
// main (the workflow never fires). `runGitVerificationStep` widens the set at capture
|
|
486
|
+
// time too — this is the symmetrical render-time guard.
|
|
487
|
+
const renderBranches = (() => {
|
|
488
|
+
if (trigger.mode !== "branches") return null;
|
|
489
|
+
if (!shouldRenderPrSourceGuardrail(spec)) return trigger.branches;
|
|
490
|
+
const git = spec.ci.git!;
|
|
491
|
+
const merged = new Set(trigger.branches);
|
|
492
|
+
merged.add(git.mainBranch);
|
|
493
|
+
if (git.devBranch) merged.add(git.devBranch);
|
|
494
|
+
return Array.from(merged);
|
|
495
|
+
})();
|
|
475
496
|
const onBlock = trigger.mode === "all-prs"
|
|
476
497
|
? ["on:", " pull_request:", " branches: ['**']"]
|
|
477
|
-
: ["on:", " pull_request:", ` branches: [${
|
|
498
|
+
: ["on:", " pull_request:", ` branches: [${renderBranches!.map((b) => `'${b}'`).join(", ")}]`];
|
|
478
499
|
const setupNode = [
|
|
479
500
|
" - uses: actions/checkout@v4",
|
|
480
501
|
" - uses: oven-sh/setup-bun@v2",
|
|
@@ -483,7 +504,7 @@ function renderGithubActionsWorkflow(spec: HarnessDesignSpec): string {
|
|
|
483
504
|
];
|
|
484
505
|
const installStep = " - run: bun install";
|
|
485
506
|
const runStep = ` - run: ${spec.ci.localCommand}`;
|
|
486
|
-
|
|
507
|
+
const lines: string[] = [
|
|
487
508
|
"# Generated by /supi:harness.",
|
|
488
509
|
"name: Harness Quality",
|
|
489
510
|
...onBlock,
|
|
@@ -497,8 +518,44 @@ function renderGithubActionsWorkflow(spec: HarnessDesignSpec): string {
|
|
|
497
518
|
...setupNode,
|
|
498
519
|
installStep,
|
|
499
520
|
runStep,
|
|
500
|
-
|
|
501
|
-
|
|
521
|
+
];
|
|
522
|
+
|
|
523
|
+
// PR-source guardrail: when the design recorded a dev branch and asked us to enforce
|
|
524
|
+
// "main only accepts PRs from dev", append a deterministic job that fails the PR
|
|
525
|
+
// check whenever a PR targets main from a branch other than dev. This is the
|
|
526
|
+
// workflow-side complement to the optional GitHub ruleset (which the user may not
|
|
527
|
+
// have permission to install) — together they provide defense-in-depth.
|
|
528
|
+
//
|
|
529
|
+
// Safety: `git.mainBranch` / `git.devBranch` are validated by `isSafeBranchName` at
|
|
530
|
+
// capture in `runGitVerificationQa`, so the single-quoted YAML expression and the
|
|
531
|
+
// double-quoted shell line below cannot be broken by injected metacharacters.
|
|
532
|
+
if (shouldRenderPrSourceGuardrail(spec)) {
|
|
533
|
+
const git = spec.ci.git!;
|
|
534
|
+
lines.push(
|
|
535
|
+
" verify-pr-source:",
|
|
536
|
+
` if: github.event.pull_request.base.ref == '${git.mainBranch}'`,
|
|
537
|
+
" runs-on: ubuntu-latest",
|
|
538
|
+
" steps:",
|
|
539
|
+
" - name: Reject PRs into main from non-dev branches",
|
|
540
|
+
" run: |",
|
|
541
|
+
` if [ "${PR_HEAD_REF_EXPR}" != "${git.devBranch}" ]; then`,
|
|
542
|
+
` echo "PRs into '${git.mainBranch}' must come from '${git.devBranch}'." >&2`,
|
|
543
|
+
" exit 1",
|
|
544
|
+
" fi",
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
lines.push("");
|
|
549
|
+
return lines.join("\n");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function shouldRenderPrSourceGuardrail(spec: HarnessDesignSpec): boolean {
|
|
553
|
+
const git = spec.ci.git;
|
|
554
|
+
if (!git) return false;
|
|
555
|
+
if (!git.enforceMainFromDevOnly) return false;
|
|
556
|
+
if (!git.devBranch) return false;
|
|
557
|
+
if (git.devBranch === git.mainBranch) return false;
|
|
558
|
+
return true;
|
|
502
559
|
}
|
|
503
560
|
|
|
504
561
|
async function applyAntiSlopBackend(
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
import * as fs from "node:fs";
|
|
20
20
|
import * as path from "node:path";
|
|
21
21
|
|
|
22
|
+
import { parse as parseYaml } from "yaml";
|
|
23
|
+
|
|
22
24
|
import type { Platform } from "../../platform/types.js";
|
|
23
25
|
import type {
|
|
24
26
|
HarnessAntiSlopBackend,
|
|
@@ -53,6 +55,7 @@ import {
|
|
|
53
55
|
getHarnessAgentsMdPath,
|
|
54
56
|
getHarnessArchitectureDocPath,
|
|
55
57
|
getHarnessGoldenPrinciplesPath,
|
|
58
|
+
getHarnessSessionDir,
|
|
56
59
|
} from "../project-paths.js";
|
|
57
60
|
import { resolveDocsConfig } from "../docs/config.js";
|
|
58
61
|
import { matchesLayerGlob } from "../docs/glob-match.js";
|
|
@@ -279,6 +282,63 @@ function workflowGrantsPrCommentPermission(workflow: string): boolean {
|
|
|
279
282
|
return false;
|
|
280
283
|
}
|
|
281
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Structurally inspect the rendered workflow for a healthy `verify-pr-source` job.
|
|
287
|
+
*
|
|
288
|
+
* Returns `null` when the job is present, gated on the configured `mainBranch`, and
|
|
289
|
+
* its shell guard names the configured `devBranch`. Returns a short description of
|
|
290
|
+
* the first mismatch otherwise.
|
|
291
|
+
*
|
|
292
|
+
* Parsing the YAML (rather than substring-matching the source) is what catches:
|
|
293
|
+
* - a comment mentioning `verify-pr-source` (the substring lives, the job doesn't),
|
|
294
|
+
* - a stale job referencing a previous mainBranch/devBranch pair after the spec
|
|
295
|
+
* was updated and the workflow was not re-rendered,
|
|
296
|
+
* - a structurally wrong job (no `if`, no `run` block, etc.).
|
|
297
|
+
*
|
|
298
|
+
* Unparseable YAML falls back to a substring sanity check so a malformed workflow
|
|
299
|
+
* still reports *something* useful instead of silently passing.
|
|
300
|
+
*/
|
|
301
|
+
function inspectPrSourceGuardrailJob(
|
|
302
|
+
workflow: string,
|
|
303
|
+
mainBranch: string,
|
|
304
|
+
devBranch: string,
|
|
305
|
+
): string | null {
|
|
306
|
+
let doc: unknown;
|
|
307
|
+
try {
|
|
308
|
+
doc = parseYaml(workflow);
|
|
309
|
+
} catch {
|
|
310
|
+
return workflow.includes("verify-pr-source")
|
|
311
|
+
? "workflow YAML is unparseable; cannot confirm guardrail is correct"
|
|
312
|
+
: "workflow YAML is unparseable and contains no verify-pr-source job";
|
|
313
|
+
}
|
|
314
|
+
if (!doc || typeof doc !== "object") return "workflow root is not a mapping";
|
|
315
|
+
const jobs = (doc as { jobs?: unknown }).jobs;
|
|
316
|
+
if (!jobs || typeof jobs !== "object") return "workflow has no `jobs:` mapping";
|
|
317
|
+
const job = (jobs as Record<string, unknown>)["verify-pr-source"];
|
|
318
|
+
if (!job || typeof job !== "object") return "job `verify-pr-source` is not defined";
|
|
319
|
+
const ifExpr = (job as { if?: unknown }).if;
|
|
320
|
+
if (typeof ifExpr !== "string" || !ifExpr.includes(`'${mainBranch}'`)) {
|
|
321
|
+
return `job \`verify-pr-source\` is not gated on mainBranch '${mainBranch}'`;
|
|
322
|
+
}
|
|
323
|
+
// The shell guard lives in steps[*].run; concatenate every run block we find so we
|
|
324
|
+
// do not depend on the exact step order or composition.
|
|
325
|
+
const steps = (job as { steps?: unknown }).steps;
|
|
326
|
+
const runBlocks: string[] = [];
|
|
327
|
+
if (Array.isArray(steps)) {
|
|
328
|
+
for (const step of steps) {
|
|
329
|
+
if (step && typeof step === "object") {
|
|
330
|
+
const run = (step as { run?: unknown }).run;
|
|
331
|
+
if (typeof run === "string") runBlocks.push(run);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const combinedRun = runBlocks.join("\n");
|
|
336
|
+
if (!combinedRun.includes(`"${devBranch}"`)) {
|
|
337
|
+
return `job \`verify-pr-source\` does not guard against devBranch '${devBranch}'`;
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
|
|
282
342
|
async function checkCiLocalWiring(
|
|
283
343
|
paths: HarnessStageRunnerContext["paths"],
|
|
284
344
|
cwd: string,
|
|
@@ -395,6 +455,28 @@ async function checkCiLocalWiring(
|
|
|
395
455
|
source: "ci-local-wiring",
|
|
396
456
|
});
|
|
397
457
|
}
|
|
458
|
+
|
|
459
|
+
// When the design recorded git verification with `enforceMainFromDevOnly: true`,
|
|
460
|
+
// confirm the workflow actually contains the `verify-pr-source` job that the
|
|
461
|
+
// implement stage is supposed to render, with an `if:` that names the current
|
|
462
|
+
// mainBranch and a shell guard that names the current devBranch. A missing or
|
|
463
|
+
// stale job means CI-side enforcement is silently absent — surface it as an error
|
|
464
|
+
// so the user notices. Substring search is intentionally avoided: a comment
|
|
465
|
+
// containing "verify-pr-source", or a stale job from a previous main/dev pairing,
|
|
466
|
+
// would pass a `workflow.includes(...)` check but provide no real enforcement.
|
|
467
|
+
const git = spec.value.ci.git;
|
|
468
|
+
if (git && git.enforceMainFromDevOnly && git.devBranch) {
|
|
469
|
+
const issue = inspectPrSourceGuardrailJob(workflow, git.mainBranch, git.devBranch);
|
|
470
|
+
if (issue) {
|
|
471
|
+
findings.push({
|
|
472
|
+
severity: "error",
|
|
473
|
+
file: spec.value.ci.workflowPath,
|
|
474
|
+
message: `CI workflow's verify-pr-source job is missing or stale: ${issue}.`,
|
|
475
|
+
remediation: `Re-run /supi:harness so the workflow re-renders with the dev/main guardrail (dev=${git.devBranch}, main=${git.mainBranch}).`,
|
|
476
|
+
source: "ci-local-wiring",
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
398
480
|
} catch (error) {
|
|
399
481
|
findings.push({
|
|
400
482
|
severity: "error",
|
|
@@ -406,6 +488,32 @@ async function checkCiLocalWiring(
|
|
|
406
488
|
}
|
|
407
489
|
}
|
|
408
490
|
|
|
491
|
+
// Bubble git-verification findings recorded by the interactive QA step into the
|
|
492
|
+
// validate report. The QA helper records non-fatal issues (gh missing, no permission)
|
|
493
|
+
// so the user sees them in the validation output even if the workflow itself is fine.
|
|
494
|
+
// When a bubbled finding has no remediation of its own, fall back to the manual
|
|
495
|
+
// instructions doc — but only when one was actually written (`manualInstructionsPath`
|
|
496
|
+
// is set). The previous literal-`<session>` placeholder was never substituted and
|
|
497
|
+
// pointed at a file that was never written for declined / completed verifications.
|
|
498
|
+
const gitVerification = spec.value.ci.git?.verification;
|
|
499
|
+
if (gitVerification) {
|
|
500
|
+
const manualPath = gitVerification.manualInstructionsPath
|
|
501
|
+
? path.join(getHarnessSessionDir(paths, cwd, sessionId), gitVerification.manualInstructionsPath)
|
|
502
|
+
: null;
|
|
503
|
+
const fallbackRemediation = manualPath
|
|
504
|
+
? `See ${manualPath} for manual steps.`
|
|
505
|
+
: "Re-run /supi:harness to retry git verification.";
|
|
506
|
+
for (const finding of gitVerification.findings) {
|
|
507
|
+
findings.push({
|
|
508
|
+
severity: finding.severity,
|
|
509
|
+
file: spec.value.ci.workflowPath,
|
|
510
|
+
message: `git-verify: ${finding.message}`,
|
|
511
|
+
remediation: finding.remediation ?? fallbackRemediation,
|
|
512
|
+
source: "ci-local-wiring",
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
409
517
|
return {
|
|
410
518
|
name: "ci-local-wiring",
|
|
411
519
|
passed: !findings.some((finding) => finding.severity === "error"),
|