supipowers 2.0.2 → 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/README.md +5 -6
- package/package.json +4 -2
- package/skills/harness/SKILL.md +1 -0
- package/src/bootstrap.ts +8 -133
- package/src/commands/optimize-context.ts +153 -16
- package/src/commands/runbook.ts +511 -0
- package/src/config/defaults.ts +5 -5
- package/src/config/loader.ts +1 -0
- package/src/config/schema.ts +2 -6
- 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/context-mode/knowledge/store.ts +381 -43
- package/src/context-mode/tools.ts +41 -3
- package/src/deps/registry.ts +1 -12
- package/src/fix-pr/assessment.ts +1 -0
- package/src/fix-pr/prompt-builder.ts +1 -0
- package/src/git/commit.ts +76 -18
- package/src/harness/command.ts +201 -12
- package/src/harness/default-agents/docs.md +39 -0
- package/src/harness/docs/config.ts +29 -0
- package/src/harness/docs/glob-match.ts +27 -0
- package/src/harness/docs/index-renderer.ts +82 -0
- package/src/harness/docs/provenance.ts +125 -0
- package/src/harness/docs/regen-decision.ts +167 -0
- package/src/harness/docs/representative-files.ts +175 -0
- package/src/harness/docs/source-hash.ts +106 -0
- package/src/harness/docs/validator.ts +233 -0
- package/src/harness/git-verification.ts +515 -0
- package/src/harness/git-verify-qa.ts +406 -0
- package/src/harness/hooks/layer-context-inject.ts +35 -1
- package/src/harness/hooks/register.ts +24 -3
- package/src/harness/pipeline.ts +37 -13
- package/src/harness/pr-comment/baseline.ts +105 -0
- package/src/harness/pr-comment/ci-env.ts +120 -0
- package/src/harness/pr-comment/gh-poster.ts +227 -0
- package/src/harness/pr-comment/handler.ts +198 -0
- package/src/harness/pr-comment/render.ts +297 -0
- package/src/harness/pr-comment/status.ts +95 -0
- package/src/harness/pr-comment/types.ts +73 -0
- package/src/harness/pr-comment/workflow-summary.ts +47 -0
- package/src/harness/project-paths.ts +95 -0
- package/src/harness/stages/design.ts +1 -0
- package/src/harness/stages/discover.ts +1 -13
- package/src/harness/stages/docs.ts +708 -0
- package/src/harness/stages/implement-apply.ts +934 -0
- package/src/harness/stages/implement.ts +64 -51
- package/src/harness/stages/plan.ts +25 -16
- package/src/harness/stages/validate.ts +478 -0
- package/src/harness/storage.ts +142 -0
- package/src/harness/tools.ts +130 -0
- package/src/mempalace/bridge.ts +207 -41
- package/src/mempalace/config.ts +10 -4
- package/src/mempalace/format.ts +122 -6
- package/src/mempalace/hooks.ts +204 -56
- package/src/mempalace/installer-helper.ts +18 -4
- package/src/mempalace/python/mempalace_bridge.py +128 -3
- package/src/mempalace/runtime.ts +53 -16
- package/src/mempalace/schema.ts +151 -30
- package/src/mempalace/session-summary.ts +5 -0
- package/src/mempalace/tool.ts +17 -4
- package/src/mempalace/upstream-limits.ts +69 -0
- package/src/planning/approval-flow.ts +25 -2
- package/src/planning/planning-ask-tool.ts +34 -4
- package/src/planning/system-prompt.ts +1 -1
- package/src/tool-catalog/active-tool-controller.ts +0 -22
- package/src/tool-catalog/active-tool-planner.ts +0 -26
- package/src/tool-catalog/tool-groups.ts +1 -9
- package/src/types.ts +127 -8
- package/src/ui-design/session.ts +114 -8
- package/src/utils/executable.ts +10 -1
- package/src/workspace/state-paths.ts +1 -1
- package/src/commands/mcp.ts +0 -814
- package/src/mcp/activation.ts +0 -77
- package/src/mcp/config.ts +0 -223
- package/src/mcp/docs.ts +0 -154
- package/src/mcp/gateway.ts +0 -103
- package/src/mcp/lifecycle.ts +0 -79
- package/src/mcp/manager-tool.ts +0 -104
- package/src/mcp/mcpc.ts +0 -113
- package/src/mcp/registry.ts +0 -98
- package/src/mcp/triggers.ts +0 -62
- package/src/mcp/types.ts +0 -95
|
@@ -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
|
+
}
|
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
getHarnessArchitectureDocPath,
|
|
32
32
|
getHarnessMarkerPath,
|
|
33
33
|
} from "../project-paths.js";
|
|
34
|
+
import { extractAgentContextSection } from "../docs/validator.js";
|
|
35
|
+
import { parseProvenance } from "../docs/provenance.js";
|
|
34
36
|
|
|
35
37
|
export interface LayerContextHookOptions {
|
|
36
38
|
/**
|
|
@@ -86,6 +88,13 @@ export interface LayerContextInjectionResult {
|
|
|
86
88
|
/**
|
|
87
89
|
* Compute the addendum for a single hook invocation. Pure-ish: reads the file system but
|
|
88
90
|
* never mutates state. Tests call this directly with a known cwd + candidate file.
|
|
91
|
+
*
|
|
92
|
+
* Resolution order:
|
|
93
|
+
* 1. If `docs/layers/<layerId>.md` exists, extract its `## Agent context` section and
|
|
94
|
+
* return it (capped at `addendum_max_chars`). This is the preferred path once the
|
|
95
|
+
* docs stage has run.
|
|
96
|
+
* 2. Otherwise, fall back to the architecture-doc-derived addendum so projects that
|
|
97
|
+
* have not generated per-layer docs still receive a useful reminder.
|
|
89
98
|
*/
|
|
90
99
|
export function computeLayerAddendum(input: {
|
|
91
100
|
cwd: string;
|
|
@@ -93,6 +102,8 @@ export function computeLayerAddendum(input: {
|
|
|
93
102
|
config: HarnessHookConfig["layer_context_inject"];
|
|
94
103
|
/** Override the resolved architecture-doc path; tests use this to point at a fixture. */
|
|
95
104
|
archPath?: string;
|
|
105
|
+
/** Override the resolved per-layer doc path; tests use this to point at a fixture. */
|
|
106
|
+
layerDocPath?: (layerId: string) => string;
|
|
96
107
|
}): LayerContextInjectionResult {
|
|
97
108
|
if (!input.config.enabled) return { addendum: "", reason: "disabled" };
|
|
98
109
|
if (!input.candidateFile) return { addendum: "", reason: "no candidate file" };
|
|
@@ -101,8 +112,31 @@ export function computeLayerAddendum(input: {
|
|
|
101
112
|
if (rules.length === 0) return { addendum: "", reason: "no rules parsed" };
|
|
102
113
|
const rule = resolveLayerForFile(input.candidateFile, rules);
|
|
103
114
|
if (!rule) return { addendum: "", reason: "no rule matches candidate file" };
|
|
115
|
+
|
|
116
|
+
// Preferred path: per-layer agent doc.
|
|
117
|
+
const docPath = input.layerDocPath
|
|
118
|
+
? input.layerDocPath(rule.layer)
|
|
119
|
+
: `${input.cwd}/docs/layers/${rule.layer}.md`;
|
|
120
|
+
if (fs.existsSync(docPath)) {
|
|
121
|
+
try {
|
|
122
|
+
const contents = fs.readFileSync(docPath, "utf8");
|
|
123
|
+
const parsed = parseProvenance(contents);
|
|
124
|
+
const body = parsed ? parsed.body : contents;
|
|
125
|
+
const section = extractAgentContextSection(body);
|
|
126
|
+
if (section.length > 0) {
|
|
127
|
+
const cap = input.config.addendum_max_chars;
|
|
128
|
+
const capped = section.length <= cap
|
|
129
|
+
? section
|
|
130
|
+
: `${section.slice(0, Math.max(0, cap - 1))}…`;
|
|
131
|
+
return { addendum: capped, reason: "matched (per-layer doc)" };
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
// fall through to architecture-doc fallback on any read error
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
104
138
|
const addendum = buildLayerAddendum(input.candidateFile, rule, input.config.addendum_max_chars);
|
|
105
|
-
return { addendum, reason: "matched" };
|
|
139
|
+
return { addendum, reason: "matched (architecture.md fallback)" };
|
|
106
140
|
}
|
|
107
141
|
|
|
108
142
|
/**
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { Platform } from "../../platform/types.js";
|
|
15
|
-
import type { HarnessConfig, HarnessHookConfig } from "../../types.js";
|
|
15
|
+
import type { HarnessConfig, HarnessDocsConfig, HarnessHookConfig } from "../../types.js";
|
|
16
16
|
import { buildBackendAdapter } from "../anti_slop/backend-factory.js";
|
|
17
17
|
import {
|
|
18
18
|
registerLayerContextInjectHook,
|
|
@@ -31,9 +31,21 @@ export const DEFAULT_HARNESS_HOOK_CONFIG: HarnessHookConfig = {
|
|
|
31
31
|
score_floor: { strict: 75, lenient: 90, release_blocking: false },
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
export const DEFAULT_HARNESS_DOCS_CONFIG: HarnessDocsConfig = {
|
|
35
|
+
tier: "simple",
|
|
36
|
+
max_per_doc_loc: 150,
|
|
37
|
+
agent_context_loc: 30,
|
|
38
|
+
max_index_loc: 50,
|
|
39
|
+
max_units: 12,
|
|
40
|
+
max_concurrent_subagents: null,
|
|
41
|
+
drift_warning: { enabled: true },
|
|
42
|
+
regen_preview_threshold: 1,
|
|
43
|
+
};
|
|
44
|
+
|
|
34
45
|
export const DEFAULT_HARNESS_CONFIG: HarnessConfig = {
|
|
35
46
|
anti_slop: DEFAULT_HARNESS_HOOK_CONFIG,
|
|
36
47
|
implement_in_session_threshold: 10,
|
|
48
|
+
docs: DEFAULT_HARNESS_DOCS_CONFIG,
|
|
37
49
|
};
|
|
38
50
|
|
|
39
51
|
export interface HarnessHookRegistration {
|
|
@@ -54,19 +66,28 @@ export interface RegisterHooksOptions {
|
|
|
54
66
|
* unless a real resolver is wired).
|
|
55
67
|
*/
|
|
56
68
|
resolveCandidateFile?: (event: unknown, ctx: unknown) => string | null;
|
|
69
|
+
/** CWD whose repo-local marker controls registration. Defaults to process.cwd(). */
|
|
70
|
+
cwd?: string;
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
// Re-export so existing call sites keep working without an import path change.
|
|
60
74
|
export { buildBackendAdapter };
|
|
61
75
|
|
|
62
76
|
/**
|
|
63
|
-
* Register every harness hook.
|
|
64
|
-
*
|
|
77
|
+
* Register every harness hook. Hooks subscribe unconditionally at bootstrap time; each
|
|
78
|
+
* hook checks the repo-local marker per event, so creating the marker after install
|
|
79
|
+
* activates already-registered handlers without an OMP restart, and removing the marker
|
|
80
|
+
* disables them. `dispose()` is idempotent.
|
|
81
|
+
*
|
|
82
|
+
* The `cwd` option is retained for tests that exercise the legacy marker check; it is
|
|
83
|
+
* unused by the new registration path because per-event handlers resolve cwd from the
|
|
84
|
+
* event payload.
|
|
65
85
|
*/
|
|
66
86
|
export function registerHarnessHooks(
|
|
67
87
|
platform: Platform,
|
|
68
88
|
options: RegisterHooksOptions = {},
|
|
69
89
|
): HarnessHookRegistration {
|
|
90
|
+
void options.cwd; // reserved for future per-repo gating
|
|
70
91
|
const backend = options.backend ?? "fallow";
|
|
71
92
|
const hooks = options.hooks ?? DEFAULT_HARNESS_HOOK_CONFIG;
|
|
72
93
|
const adapter = buildBackendAdapter(backend);
|
package/src/harness/pipeline.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from "./stages/design.js";
|
|
32
32
|
import { HarnessPlanStage, type PlanStageInput } from "./stages/plan.js";
|
|
33
33
|
import { HarnessImplementStage, type ImplementStageInput } from "./stages/implement.js";
|
|
34
|
+
import { HarnessDocsStage, type DocsStageInput } from "./stages/docs.js";
|
|
34
35
|
import { HarnessValidateStage, type ValidateStageInput } from "./stages/validate.js";
|
|
35
36
|
import { loadHarnessDesignSpecJson, loadHarnessDiscover } from "./storage.js";
|
|
36
37
|
import { buildBackendAdapter } from "./anti_slop/backend-factory.js";
|
|
@@ -52,6 +53,7 @@ const STAGE_ORDER: readonly HarnessStage[] = [
|
|
|
52
53
|
"design",
|
|
53
54
|
"plan",
|
|
54
55
|
"implement",
|
|
56
|
+
"docs",
|
|
55
57
|
"validate",
|
|
56
58
|
];
|
|
57
59
|
|
|
@@ -60,6 +62,7 @@ const GATE_STAGES_DEFAULT: ReadonlySet<HarnessStage> = new Set([
|
|
|
60
62
|
"discover",
|
|
61
63
|
"design",
|
|
62
64
|
"plan",
|
|
65
|
+
"docs",
|
|
63
66
|
"validate",
|
|
64
67
|
]);
|
|
65
68
|
const GATE_STAGES_MANUAL: ReadonlySet<HarnessStage> = new Set([
|
|
@@ -68,6 +71,7 @@ const GATE_STAGES_MANUAL: ReadonlySet<HarnessStage> = new Set([
|
|
|
68
71
|
"design",
|
|
69
72
|
"plan",
|
|
70
73
|
"implement",
|
|
74
|
+
"docs",
|
|
71
75
|
"validate",
|
|
72
76
|
]);
|
|
73
77
|
|
|
@@ -89,6 +93,8 @@ export interface BuildRunnerInput {
|
|
|
89
93
|
planInput?: PlanStageInput;
|
|
90
94
|
/** Required when running the implement stage. */
|
|
91
95
|
implementInput?: ImplementStageInput;
|
|
96
|
+
/** Optional override for the docs stage (tier, max-units, test-only factories). */
|
|
97
|
+
docsInput?: DocsStageInput;
|
|
92
98
|
/** Required when running the validate stage. */
|
|
93
99
|
validateInput?: ValidateStageInput;
|
|
94
100
|
}
|
|
@@ -111,6 +117,8 @@ export function buildHarnessRunner(stage: HarnessStage, input: BuildRunnerInput)
|
|
|
111
117
|
throw new Error("buildHarnessRunner: implement stage requires implementInput");
|
|
112
118
|
}
|
|
113
119
|
return new HarnessImplementStage(input.implementInput);
|
|
120
|
+
case "docs":
|
|
121
|
+
return new HarnessDocsStage(input.docsInput ?? {});
|
|
114
122
|
case "validate":
|
|
115
123
|
if (!input.validateInput) {
|
|
116
124
|
throw new Error("buildHarnessRunner: validate stage requires validateInput");
|
|
@@ -143,6 +151,12 @@ export interface PipelineDriverInput {
|
|
|
143
151
|
* complete). Used by per-stage subcommands.
|
|
144
152
|
*/
|
|
145
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>;
|
|
146
160
|
/** Hard cap on stage iterations. */
|
|
147
161
|
safetyLimit?: number;
|
|
148
162
|
/** Optional callback for progress events (wire up a progress widget). */
|
|
@@ -215,6 +229,15 @@ function formatStageDetail(result: HarnessStageRunResult): string {
|
|
|
215
229
|
const layers = typeof d.layerCount === "number" ? `${d.layerCount} layers` : "";
|
|
216
230
|
return layers ? `${backend} · ${layers}` : `${backend}`;
|
|
217
231
|
}
|
|
232
|
+
if (result.stage === "docs") {
|
|
233
|
+
const regen = Array.isArray(d.regenerated) ? (d.regenerated as string[]).length : 0;
|
|
234
|
+
const skip = Array.isArray(d.skipped) ? (d.skipped as string[]).length : 0;
|
|
235
|
+
const user = Array.isArray(d.userEdited) ? (d.userEdited as string[]).length : 0;
|
|
236
|
+
if (typeof d.tier === "string" && d.tier === "extensive") {
|
|
237
|
+
return `${regen} regen · ${skip} skip${user > 0 ? ` · ${user} user-edited` : ""}`;
|
|
238
|
+
}
|
|
239
|
+
if (typeof d.reason === "string") return d.reason;
|
|
240
|
+
}
|
|
218
241
|
if (result.stage === "validate" && typeof d.passed === "boolean") {
|
|
219
242
|
return d.passed ? "passed" : "issues found";
|
|
220
243
|
}
|
|
@@ -252,14 +275,17 @@ export async function runHarnessPipelineUntilGate(
|
|
|
252
275
|
const stageInputs = ensureStageInputs(input, stage);
|
|
253
276
|
const runner = buildHarnessRunner(stage, stageInputs);
|
|
254
277
|
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
+
});
|
|
263
289
|
|
|
264
290
|
if (isComplete) {
|
|
265
291
|
input.onProgress?.({ type: "stage-skipped", stage });
|
|
@@ -279,9 +305,9 @@ export async function runHarnessPipelineUntilGate(
|
|
|
279
305
|
|
|
280
306
|
const result = await runner.run(ctx);
|
|
281
307
|
|
|
282
|
-
// In auto mode, awaiting-user is equivalent to
|
|
283
|
-
//
|
|
284
|
-
//
|
|
308
|
+
// In auto mode, awaiting-user from authoring stages (design, etc.) is equivalent to
|
|
309
|
+
// completed: the artifact is on disk and the next stage can consume it. Gates honor
|
|
310
|
+
// awaiting-user as a real stop signal.
|
|
285
311
|
const isGate = gateStages.has(stage);
|
|
286
312
|
const normalizedStatus: HarnessStageRunResult["status"] =
|
|
287
313
|
result.status === "awaiting-user" && !isGate
|
|
@@ -307,8 +333,6 @@ export async function runHarnessPipelineUntilGate(
|
|
|
307
333
|
};
|
|
308
334
|
}
|
|
309
335
|
|
|
310
|
-
// In auto mode, awaiting-user is equivalent to completed — the pipeline
|
|
311
|
-
// continues without stopping. Only surface the distinction when gated.
|
|
312
336
|
if (normalizedStatus === "awaiting-user" && isGate) {
|
|
313
337
|
input.onProgress?.({ type: "awaiting-user", stage, detail: awaitUserDetail(result) });
|
|
314
338
|
} else {
|