supipowers 2.0.2 → 2.1.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 +5 -133
- package/src/config/defaults.ts +5 -5
- package/src/config/loader.ts +1 -0
- package/src/config/schema.ts +2 -6
- 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 +103 -6
- 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/hooks/layer-context-inject.ts +35 -1
- package/src/harness/hooks/register.ts +24 -3
- package/src/harness/pipeline.ts +20 -5
- 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 +877 -0
- package/src/harness/stages/implement.ts +64 -51
- package/src/harness/stages/plan.ts +25 -16
- package/src/harness/stages/validate.ts +370 -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 +87 -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,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler for `/supi:harness pr-comment`.
|
|
3
|
+
*
|
|
4
|
+
* Resolves a validate report → baseline → CI context → renders → posts (or writes a
|
|
5
|
+
* step-summary fallback). Always notifies the UI with a one-line outcome; never throws.
|
|
6
|
+
*
|
|
7
|
+
* Flags (parsed via `parseFlags`):
|
|
8
|
+
* --dry-run Print the body to stdout/UI, no `gh` call, no env required.
|
|
9
|
+
* --pr=<n> Override PR number (otherwise read from env).
|
|
10
|
+
* --repo=<owner>/<repo> Override repo (otherwise read from GITHUB_REPOSITORY).
|
|
11
|
+
* --session=<id> Override which session's validate report we render.
|
|
12
|
+
* --mode=every-push|on-status-change
|
|
13
|
+
* Override the config-supplied posting cadence.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Platform } from "../../platform/types.js";
|
|
17
|
+
import type { HarnessCommandContext } from "../command.js";
|
|
18
|
+
import {
|
|
19
|
+
listHarnessSessions,
|
|
20
|
+
loadHarnessDesignSpecJson,
|
|
21
|
+
loadHarnessValidateReport,
|
|
22
|
+
} from "../storage.js";
|
|
23
|
+
import { renderHarnessPrComment } from "./render.js";
|
|
24
|
+
import { loadBaseline } from "./baseline.js";
|
|
25
|
+
import { detectCiContext } from "./ci-env.js";
|
|
26
|
+
import { postStickyComment } from "./gh-poster.js";
|
|
27
|
+
import { STICKY_MARKER_PREFIX } from "./status.js";
|
|
28
|
+
import { writeStepSummary } from "./workflow-summary.js";
|
|
29
|
+
|
|
30
|
+
interface ParsedFlags {
|
|
31
|
+
dryRun: boolean;
|
|
32
|
+
prNumber?: number;
|
|
33
|
+
repo?: string;
|
|
34
|
+
sessionId?: string;
|
|
35
|
+
mode?: "every-push" | "on-status-change";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Default floor used when no design spec exists yet (purely defensive). */
|
|
39
|
+
const DEFAULT_SCORE_FLOOR = { strict: 75, lenient: 90 } as const;
|
|
40
|
+
|
|
41
|
+
/** Default mode when neither flag nor config supplies one. */
|
|
42
|
+
const DEFAULT_MODE = "every-push" satisfies NonNullable<ParsedFlags["mode"]>;
|
|
43
|
+
|
|
44
|
+
export async function handlePrComment(
|
|
45
|
+
platform: Platform,
|
|
46
|
+
ctx: HarnessCommandContext,
|
|
47
|
+
args: readonly string[],
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
const flags = parseFlags(args);
|
|
50
|
+
|
|
51
|
+
// 1. Pick the session.
|
|
52
|
+
const sessionId = flags.sessionId ?? listHarnessSessions(platform.paths, ctx.cwd)[0];
|
|
53
|
+
if (!sessionId) {
|
|
54
|
+
ctx.ui.notify(
|
|
55
|
+
"No harness session found. Run `/supi:harness validate` first or pass --session=<id>.",
|
|
56
|
+
"error",
|
|
57
|
+
);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2. Load the validate report.
|
|
62
|
+
const reportResult = loadHarnessValidateReport(platform.paths, ctx.cwd, sessionId);
|
|
63
|
+
if (!reportResult.ok) {
|
|
64
|
+
ctx.ui.notify(
|
|
65
|
+
`Cannot read validate report for session ${sessionId}: ${reportResult.error.message}`,
|
|
66
|
+
"error",
|
|
67
|
+
);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const report = reportResult.value;
|
|
71
|
+
|
|
72
|
+
// 3. Load baseline + score floor + config-supplied mode from the design spec.
|
|
73
|
+
const baseline = loadBaseline(platform.paths, ctx.cwd, { currentSessionId: sessionId });
|
|
74
|
+
const designSpec = loadHarnessDesignSpecJson(platform.paths, ctx.cwd, sessionId);
|
|
75
|
+
const scoreFloor = designSpec.ok ? designSpec.value.antiSlop.hooks.score_floor : DEFAULT_SCORE_FLOOR;
|
|
76
|
+
const configMode = designSpec.ok ? designSpec.value.ci.prComment?.mode : undefined;
|
|
77
|
+
const enabled = designSpec.ok ? designSpec.value.ci.prComment?.enabled !== false : true;
|
|
78
|
+
if (!enabled && !flags.dryRun) {
|
|
79
|
+
ctx.ui.notify(
|
|
80
|
+
"PR comments are disabled in this harness design spec (ci.prComment.enabled=false).",
|
|
81
|
+
"info",
|
|
82
|
+
);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const mode = flags.mode ?? configMode ?? DEFAULT_MODE;
|
|
87
|
+
|
|
88
|
+
// 4. CI context (flag overrides > env). For --dry-run we tolerate a missing context.
|
|
89
|
+
const ciContext = detectCiContext(process.env, {
|
|
90
|
+
repo: flags.repo,
|
|
91
|
+
prNumber: flags.prNumber,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// 5. Render.
|
|
95
|
+
const rendered = renderHarnessPrComment({
|
|
96
|
+
report,
|
|
97
|
+
previousScore: baseline.previousScore,
|
|
98
|
+
trend: baseline.trend,
|
|
99
|
+
scoreFloor: { strict: scoreFloor.strict, lenient: scoreFloor.lenient },
|
|
100
|
+
sessionId,
|
|
101
|
+
generatedAt: new Date().toISOString(),
|
|
102
|
+
runUrl: ciContext?.runUrl,
|
|
103
|
+
baseRef: ciContext?.baseRef,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// 6. Branch on transport.
|
|
107
|
+
if (flags.dryRun) {
|
|
108
|
+
ctx.ui.notify(`PR comment preview (status=${rendered.status}):\n\n${rendered.body}`, "info");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!ciContext) {
|
|
113
|
+
// No PR context — fall back to the step summary unconditionally. This is the
|
|
114
|
+
// expected path on `push` events that should not post a PR comment.
|
|
115
|
+
const summary = writeStepSummary(rendered.body);
|
|
116
|
+
if (summary.ok && summary.path) {
|
|
117
|
+
ctx.ui.notify(`No PR context; wrote summary to ${summary.path}.`, "info");
|
|
118
|
+
} else if (summary.ok) {
|
|
119
|
+
ctx.ui.notify("Skipped: no PR context and no GITHUB_STEP_SUMMARY available.", "info");
|
|
120
|
+
} else {
|
|
121
|
+
ctx.ui.notify(`Skipped: ${summary.reason ?? "no PR context"}`, "warning");
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const outcome = await postStickyComment(platform, {
|
|
127
|
+
repo: ciContext.repo,
|
|
128
|
+
prNumber: ciContext.prNumber,
|
|
129
|
+
cwd: ctx.cwd,
|
|
130
|
+
body: rendered.body,
|
|
131
|
+
mode,
|
|
132
|
+
currentStatus: rendered.status,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
switch (outcome.kind) {
|
|
136
|
+
case "created":
|
|
137
|
+
ctx.ui.notify(`PR comment created (id=${outcome.commentId}).`, "info");
|
|
138
|
+
return;
|
|
139
|
+
case "updated":
|
|
140
|
+
ctx.ui.notify(`PR comment updated (id=${outcome.commentId}).`, "info");
|
|
141
|
+
return;
|
|
142
|
+
case "unchanged":
|
|
143
|
+
ctx.ui.notify(`PR comment unchanged (status still ${rendered.status}, id=${outcome.commentId}).`, "info");
|
|
144
|
+
return;
|
|
145
|
+
case "skipped":
|
|
146
|
+
case "failed": {
|
|
147
|
+
// Fail-open: write the body to the workflow summary so the run page still has the
|
|
148
|
+
// report, then notify with a warning (not an error — this is auxiliary signal).
|
|
149
|
+
const summary = writeStepSummary(rendered.body);
|
|
150
|
+
const fallback = summary.ok && summary.path ? ` (summary at ${summary.path})` : "";
|
|
151
|
+
const reason = outcome.kind === "failed" ? outcome.reason : outcome.reason;
|
|
152
|
+
ctx.ui.notify(`PR comment ${outcome.kind}: ${reason}.${fallback}`, "warning");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Flag parsing
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
function parseFlags(args: readonly string[]): ParsedFlags {
|
|
163
|
+
const flags: ParsedFlags = { dryRun: false };
|
|
164
|
+
for (const arg of args) {
|
|
165
|
+
if (arg === "--dry-run") {
|
|
166
|
+
flags.dryRun = true;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const eq = arg.indexOf("=");
|
|
170
|
+
if (eq === -1) continue;
|
|
171
|
+
const name = arg.slice(0, eq);
|
|
172
|
+
const value = arg.slice(eq + 1);
|
|
173
|
+
switch (name) {
|
|
174
|
+
case "--pr": {
|
|
175
|
+
const n = Number(value);
|
|
176
|
+
if (Number.isFinite(n) && n > 0) flags.prNumber = n;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case "--repo":
|
|
180
|
+
flags.repo = value;
|
|
181
|
+
break;
|
|
182
|
+
case "--session":
|
|
183
|
+
flags.sessionId = value;
|
|
184
|
+
break;
|
|
185
|
+
case "--mode":
|
|
186
|
+
if (value === "every-push" || value === "on-status-change") flags.mode = value;
|
|
187
|
+
break;
|
|
188
|
+
default:
|
|
189
|
+
// Unknown flag — ignored. The dispatcher already filters by subcommand name.
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return flags;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Suppress unused-import warning for STICKY_MARKER_PREFIX — re-exported indirectly for
|
|
197
|
+
// downstream consumers that import from this module.
|
|
198
|
+
void STICKY_MARKER_PREFIX;
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure renderer for the harness PR sticky comment.
|
|
3
|
+
*
|
|
4
|
+
* No IO, no clock — every output depends only on `input`. This makes the renderer
|
|
5
|
+
* exhaustively unit-testable and reproducible. All UI lives here; the gh poster is a
|
|
6
|
+
* dumb pipe.
|
|
7
|
+
*
|
|
8
|
+
* Layout (Proposal A, locked in design conversation):
|
|
9
|
+
* 1. marker line (HTML comment, machine-parseable)
|
|
10
|
+
* 2. status banner (emoji + score + delta + blocked flag)
|
|
11
|
+
* 3. one-sentence summary
|
|
12
|
+
* 4. failed checks (auto-expanded <details open>) — per-check invariant + finding table
|
|
13
|
+
* 5. passed checks (collapsed <details>) — single-line list
|
|
14
|
+
* 6. scorecard table — dimensions × {score, Δ, open, resolved, wontfix}
|
|
15
|
+
* 7. optional trend line / collapsible
|
|
16
|
+
* 8. footer h6 — floor · session · links · attribution
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
HarnessScoreDimension,
|
|
21
|
+
HarnessValidateCheck,
|
|
22
|
+
HarnessValidateFinding,
|
|
23
|
+
HarnessValidateReport,
|
|
24
|
+
} from "../../types.js";
|
|
25
|
+
import { deriveStatus, renderMarker } from "./status.js";
|
|
26
|
+
import type {
|
|
27
|
+
PrCommentDimensionDelta,
|
|
28
|
+
PrCommentPreviousScore,
|
|
29
|
+
PrCommentStatus,
|
|
30
|
+
PrCommentTrendPoint,
|
|
31
|
+
RenderInput,
|
|
32
|
+
RenderResult,
|
|
33
|
+
} from "./types.js";
|
|
34
|
+
|
|
35
|
+
const STATUS_EMOJI: Readonly<Record<PrCommentStatus, string>> = {
|
|
36
|
+
passed: "🟢",
|
|
37
|
+
warned: "🟡",
|
|
38
|
+
failed: "🔴",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const SEVERITY_EMOJI: Readonly<Record<HarnessValidateFinding["severity"], string>> = {
|
|
42
|
+
error: "🛑",
|
|
43
|
+
warning: "⚠",
|
|
44
|
+
info: "ℹ",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const DIMENSION_LABELS: Readonly<Record<HarnessScoreDimension["name"], string>> = {
|
|
48
|
+
duplicates: "Duplicates",
|
|
49
|
+
deadCode: "Dead code",
|
|
50
|
+
layerViolations: "Layer violations",
|
|
51
|
+
other: "Other",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Render a complete PR comment body for a validate report. */
|
|
55
|
+
export function renderHarnessPrComment(input: RenderInput): RenderResult {
|
|
56
|
+
const status = deriveStatus(input.report);
|
|
57
|
+
const scoreDelta = computeScoreDelta(input.report.score.strict, input.previousScore);
|
|
58
|
+
const dimensionDeltas = computeDimensionDeltas(
|
|
59
|
+
input.report.score.dimensions,
|
|
60
|
+
input.previousScore?.dimensions ?? null,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const marker = renderMarker({
|
|
64
|
+
status,
|
|
65
|
+
strict: input.report.score.strict,
|
|
66
|
+
lenient: input.report.score.lenient,
|
|
67
|
+
sessionId: input.sessionId,
|
|
68
|
+
generatedAt: input.generatedAt,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const sections: string[] = [
|
|
72
|
+
marker,
|
|
73
|
+
"",
|
|
74
|
+
renderBanner(input, status, scoreDelta),
|
|
75
|
+
"",
|
|
76
|
+
renderSummaryLine(input, status),
|
|
77
|
+
"",
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const failed = input.report.checks.filter((check) => !check.passed);
|
|
81
|
+
const passed = input.report.checks.filter((check) => check.passed);
|
|
82
|
+
|
|
83
|
+
if (failed.length > 0) {
|
|
84
|
+
sections.push(renderFailedChecks(failed, input.report));
|
|
85
|
+
sections.push("");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (passed.length > 0) {
|
|
89
|
+
sections.push(renderPassedChecks(passed, failed.length === 0));
|
|
90
|
+
sections.push("");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
sections.push(renderScorecard(input.report.score.dimensions, dimensionDeltas));
|
|
94
|
+
sections.push("");
|
|
95
|
+
|
|
96
|
+
const trendSection = renderTrend(input.trend, status);
|
|
97
|
+
if (trendSection) {
|
|
98
|
+
sections.push(trendSection);
|
|
99
|
+
sections.push("");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
sections.push(renderFooter(input));
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
body: sections.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n",
|
|
106
|
+
marker,
|
|
107
|
+
status,
|
|
108
|
+
scoreDelta,
|
|
109
|
+
dimensionDeltas,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Section renderers
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
function renderBanner(input: RenderInput, status: PrCommentStatus, scoreDelta: number): string {
|
|
118
|
+
const emoji = STATUS_EMOJI[status];
|
|
119
|
+
const score = input.report.score;
|
|
120
|
+
const deltaText = scoreDelta === 0 ? "" : ` · \`${formatSignedDelta(scoreDelta)}\``;
|
|
121
|
+
const blockedSuffix = status === "failed" ? " · **blocked**" : "";
|
|
122
|
+
return `## ${emoji} Harness · score **${score.strict}** / **${score.lenient}** strict${deltaText}${blockedSuffix}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderSummaryLine(input: RenderInput, status: PrCommentStatus): string {
|
|
126
|
+
const report = input.report;
|
|
127
|
+
const failedCount = report.checks.filter((c) => !c.passed).length;
|
|
128
|
+
const passedCount = report.checks.filter((c) => c.passed).length;
|
|
129
|
+
const totalNewSlop =
|
|
130
|
+
report.slopScan.duplicates +
|
|
131
|
+
report.slopScan.deadCode +
|
|
132
|
+
report.slopScan.layerViolations +
|
|
133
|
+
report.slopScan.other;
|
|
134
|
+
|
|
135
|
+
const parts: string[] = [];
|
|
136
|
+
if (status === "passed") {
|
|
137
|
+
parts.push(`All ${passedCount + failedCount} checks passed.`);
|
|
138
|
+
parts.push(totalNewSlop === 0 ? "No new slop." : `${totalNewSlop} slop finding(s).`);
|
|
139
|
+
} else {
|
|
140
|
+
if (failedCount > 0) {
|
|
141
|
+
parts.push(`${failedCount} check${failedCount === 1 ? "" : "s"} failed`);
|
|
142
|
+
}
|
|
143
|
+
if (totalNewSlop > 0) {
|
|
144
|
+
parts.push(`${totalNewSlop} new slop finding${totalNewSlop === 1 ? "" : "s"}`);
|
|
145
|
+
}
|
|
146
|
+
if (!report.scoreFloorPassed) {
|
|
147
|
+
parts.push(`strict score below floor (${input.scoreFloor.strict})`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (input.baseRef) parts.push(`Base: \`${input.baseRef}\``);
|
|
151
|
+
// Join with `·` for status==passed (period-separated reads weird), but with " · " for
|
|
152
|
+
// failure rows so the eye treats them as distinct facts.
|
|
153
|
+
return parts.join(status === "passed" ? " " : " · ") + (status === "passed" ? "" : ".");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function renderFailedChecks(failed: readonly HarnessValidateCheck[], report: HarnessValidateReport): string {
|
|
157
|
+
const blocks: string[] = [];
|
|
158
|
+
blocks.push(`<details open><summary><strong>Failed checks (${failed.length})</strong></summary>`);
|
|
159
|
+
blocks.push("");
|
|
160
|
+
for (const check of failed) {
|
|
161
|
+
blocks.push(`#### ❌ ${check.name}`);
|
|
162
|
+
blocks.push(`**Invariant**: ${check.invariant}`);
|
|
163
|
+
blocks.push(`**What broke**: ${escapeInline(check.summary)}`);
|
|
164
|
+
if (check.name === "anti-slop-scan") {
|
|
165
|
+
// The report carries counters but not the queue entries themselves. Surface the
|
|
166
|
+
// counters and point the reader at the backlog command for actionable detail.
|
|
167
|
+
const slop = report.slopScan;
|
|
168
|
+
blocks.push("");
|
|
169
|
+
blocks.push("| Kind | Count |");
|
|
170
|
+
blocks.push("|---|---:|");
|
|
171
|
+
blocks.push(`| Duplicates | ${slop.duplicates} |`);
|
|
172
|
+
blocks.push(`| Dead code | ${slop.deadCode} |`);
|
|
173
|
+
blocks.push(`| Layer violations | ${slop.layerViolations} |`);
|
|
174
|
+
blocks.push(`| Other | ${slop.other} |`);
|
|
175
|
+
blocks.push("");
|
|
176
|
+
blocks.push("Run `/supi:harness next` to start triage, or `/supi:harness backlog` for the full queue.");
|
|
177
|
+
} else if (check.findings.length > 0) {
|
|
178
|
+
blocks.push("");
|
|
179
|
+
blocks.push(renderFindingTable(check.findings));
|
|
180
|
+
}
|
|
181
|
+
blocks.push("");
|
|
182
|
+
}
|
|
183
|
+
blocks.push("</details>");
|
|
184
|
+
return blocks.join("\n");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function renderPassedChecks(passed: readonly HarnessValidateCheck[], collapsed: boolean): string {
|
|
188
|
+
// When everything passed we collapse by default; when there ARE failures, we still
|
|
189
|
+
// collapse the passing list because they're not actionable.
|
|
190
|
+
void collapsed; // kept for symmetry with the design — both modes collapse passing checks
|
|
191
|
+
const names = passed.map((c) => `${c.name} ✅`).join(" · ");
|
|
192
|
+
return [
|
|
193
|
+
`<details><summary>Passed checks (${passed.length})</summary>`,
|
|
194
|
+
"",
|
|
195
|
+
names,
|
|
196
|
+
"</details>",
|
|
197
|
+
].join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function renderScorecard(
|
|
201
|
+
dimensions: readonly HarnessScoreDimension[],
|
|
202
|
+
deltas: readonly PrCommentDimensionDelta[],
|
|
203
|
+
): string {
|
|
204
|
+
const deltaByName = new Map(deltas.map((d) => [d.name, d.strict]));
|
|
205
|
+
const rows: string[] = [];
|
|
206
|
+
rows.push("| Dimension | Score | Δ | Open | Resolved | Wontfix |");
|
|
207
|
+
rows.push("|---|---:|---:|---:|---:|---:|");
|
|
208
|
+
for (const dim of dimensions) {
|
|
209
|
+
const delta = deltaByName.get(dim.name);
|
|
210
|
+
const deltaCell = delta === undefined || delta === 0 ? "—" : formatSignedDelta(delta);
|
|
211
|
+
rows.push(
|
|
212
|
+
`| ${DIMENSION_LABELS[dim.name]} | ${dim.strict} | ${deltaCell} | ${dim.open} | ${dim.resolved} | ${dim.wontfix} |`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return rows.join("\n");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function renderTrend(
|
|
219
|
+
trend: readonly PrCommentTrendPoint[],
|
|
220
|
+
status: PrCommentStatus,
|
|
221
|
+
): string | null {
|
|
222
|
+
if (trend.length < 2) return null;
|
|
223
|
+
const arrow = trend.map((p) => String(p.strict)).join(" → ");
|
|
224
|
+
const line = `Trend (last ${trend.length} runs, strict): \`${arrow}\``;
|
|
225
|
+
// For passing reports, hide trend behind a collapsible to keep the comment lean.
|
|
226
|
+
if (status === "passed") {
|
|
227
|
+
return [
|
|
228
|
+
"<details><summary>Trend</summary>",
|
|
229
|
+
"",
|
|
230
|
+
line,
|
|
231
|
+
"</details>",
|
|
232
|
+
].join("\n");
|
|
233
|
+
}
|
|
234
|
+
return line;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function renderFooter(input: RenderInput): string {
|
|
238
|
+
const segments: string[] = [];
|
|
239
|
+
segments.push(`Score floor: strict ${input.scoreFloor.strict} / lenient ${input.scoreFloor.lenient}`);
|
|
240
|
+
segments.push(`Session \`${shortSessionId(input.sessionId)}\``);
|
|
241
|
+
if (input.runUrl) segments.push(`[logs](${input.runUrl})`);
|
|
242
|
+
if (input.reportArtifactUrl) segments.push(`[full report](${input.reportArtifactUrl})`);
|
|
243
|
+
segments.push("`🤖 /supi:harness validate`");
|
|
244
|
+
return `###### ${segments.join(" · ")}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Helpers
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
function renderFindingTable(findings: readonly HarnessValidateFinding[]): string {
|
|
252
|
+
const rows: string[] = [];
|
|
253
|
+
rows.push("| Severity | File | Message |");
|
|
254
|
+
rows.push("|---|---|---|");
|
|
255
|
+
for (const finding of findings) {
|
|
256
|
+
const file = finding.line ? `\`${finding.file}:${finding.line}\`` : `\`${finding.file}\``;
|
|
257
|
+
rows.push(
|
|
258
|
+
`| ${SEVERITY_EMOJI[finding.severity]} ${finding.severity} | ${file} | ${escapeInline(finding.message)} |`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return rows.join("\n");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function computeScoreDelta(currentStrict: number, previous: PrCommentPreviousScore | null): number {
|
|
265
|
+
if (!previous) return 0;
|
|
266
|
+
return currentStrict - previous.strict;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function computeDimensionDeltas(
|
|
270
|
+
current: readonly HarnessScoreDimension[],
|
|
271
|
+
previous: readonly HarnessScoreDimension[] | null,
|
|
272
|
+
): PrCommentDimensionDelta[] {
|
|
273
|
+
if (!previous) {
|
|
274
|
+
return current.map((dim) => ({ name: dim.name, strict: 0 }));
|
|
275
|
+
}
|
|
276
|
+
const prevByName = new Map(previous.map((d) => [d.name, d.strict]));
|
|
277
|
+
return current.map((dim) => {
|
|
278
|
+
const before = prevByName.get(dim.name);
|
|
279
|
+
return { name: dim.name, strict: before === undefined ? 0 : dim.strict - before };
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function formatSignedDelta(delta: number): string {
|
|
284
|
+
return delta > 0 ? `+${delta}` : String(delta);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function shortSessionId(sessionId: string): string {
|
|
288
|
+
// ULIDs are 26 chars; trim for footer readability while staying unique enough to grep.
|
|
289
|
+
if (sessionId.length <= 8) return sessionId;
|
|
290
|
+
return `${sessionId.slice(0, 6)}…${sessionId.slice(-2)}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function escapeInline(text: string): string {
|
|
294
|
+
// Newlines inside a markdown table cell break the row. Collapse to spaces.
|
|
295
|
+
// Pipe characters must be escaped or they're parsed as column separators.
|
|
296
|
+
return text.replace(/\r?\n/g, " ").replace(/\|/g, "\\|");
|
|
297
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status derivation + sticky-marker round-trip.
|
|
3
|
+
*
|
|
4
|
+
* The marker line is a stable HTML comment that:
|
|
5
|
+
* - lets the gh-poster find the existing sticky comment by prefix;
|
|
6
|
+
* - lets `on-status-change` mode parse the previously posted status without re-reading
|
|
7
|
+
* any local state (the comment body is the single source of truth).
|
|
8
|
+
*
|
|
9
|
+
* Format:
|
|
10
|
+
* <!-- supipowers:harness:v1 status=<status> strict=<n> lenient=<n> session=<id> generatedAt=<iso> -->
|
|
11
|
+
*
|
|
12
|
+
* Versioned (`:v1`) so a future change to the marker shape can coexist with old comments.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { HarnessValidateReport } from "../../types.js";
|
|
16
|
+
import type { PrCommentStatus } from "./types.js";
|
|
17
|
+
|
|
18
|
+
/** Single shared prefix used by both the renderer and the poster's lookup query. */
|
|
19
|
+
export const STICKY_MARKER_PREFIX = "<!-- supipowers:harness:v1 ";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Derive the status banner from a validate report.
|
|
23
|
+
*
|
|
24
|
+
* `report.passed` is the AND of (all checks passed) and (score floor satisfied), so we
|
|
25
|
+
* branch on its two ingredients independently rather than the combined flag — that's how
|
|
26
|
+
* we distinguish "checks passed but score below floor" (warned) from "a check actually
|
|
27
|
+
* failed" (failed).
|
|
28
|
+
*/
|
|
29
|
+
export function deriveStatus(report: HarnessValidateReport): PrCommentStatus {
|
|
30
|
+
const anyCheckFailed = report.checks.some((check) => !check.passed);
|
|
31
|
+
if (anyCheckFailed) return "failed";
|
|
32
|
+
if (!report.scoreFloorPassed) return "warned";
|
|
33
|
+
// Defensive: when checks pass and floor passes but report.passed is false, treat as
|
|
34
|
+
// failed so we don't paint over an upstream bug. In practice this branch is unreachable
|
|
35
|
+
// when callers populate the report correctly.
|
|
36
|
+
if (!report.passed) return "failed";
|
|
37
|
+
return "passed";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Fields embedded in a sticky-comment marker. */
|
|
41
|
+
export interface MarkerFields {
|
|
42
|
+
status: PrCommentStatus;
|
|
43
|
+
strict: number;
|
|
44
|
+
lenient: number;
|
|
45
|
+
sessionId: string;
|
|
46
|
+
generatedAt: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Serialize fields into the canonical marker line. */
|
|
50
|
+
export function renderMarker(fields: MarkerFields): string {
|
|
51
|
+
// `sessionId` is harness-generated (ULID-like) and never contains spaces; we still
|
|
52
|
+
// assert below so a bad input doesn't silently corrupt the marker.
|
|
53
|
+
if (/\s/.test(fields.sessionId)) {
|
|
54
|
+
throw new Error(`sessionId must not contain whitespace: ${JSON.stringify(fields.sessionId)}`);
|
|
55
|
+
}
|
|
56
|
+
return (
|
|
57
|
+
`${STICKY_MARKER_PREFIX}` +
|
|
58
|
+
`status=${fields.status} ` +
|
|
59
|
+
`strict=${fields.strict} ` +
|
|
60
|
+
`lenient=${fields.lenient} ` +
|
|
61
|
+
`session=${fields.sessionId} ` +
|
|
62
|
+
`generatedAt=${fields.generatedAt} ` +
|
|
63
|
+
`-->`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Best-effort parse of a marker line. Returns null when the body does not start with
|
|
69
|
+
* STICKY_MARKER_PREFIX or any required field is missing. We deliberately do not throw —
|
|
70
|
+
* callers treat "unparseable" as "no previous comment".
|
|
71
|
+
*/
|
|
72
|
+
export function parseMarker(body: string): MarkerFields | null {
|
|
73
|
+
const firstNewline = body.indexOf("\n");
|
|
74
|
+
const head = firstNewline === -1 ? body : body.slice(0, firstNewline);
|
|
75
|
+
if (!head.startsWith(STICKY_MARKER_PREFIX)) return null;
|
|
76
|
+
// Strip the prefix + trailing "-->", then split on whitespace.
|
|
77
|
+
const inner = head.slice(STICKY_MARKER_PREFIX.length).replace(/\s*-->\s*$/, "").trim();
|
|
78
|
+
if (inner.length === 0) return null;
|
|
79
|
+
const tokens = inner.split(/\s+/);
|
|
80
|
+
const map: Record<string, string> = {};
|
|
81
|
+
for (const token of tokens) {
|
|
82
|
+
const eq = token.indexOf("=");
|
|
83
|
+
if (eq === -1) continue;
|
|
84
|
+
map[token.slice(0, eq)] = token.slice(eq + 1);
|
|
85
|
+
}
|
|
86
|
+
const status = map.status;
|
|
87
|
+
const strict = Number(map.strict);
|
|
88
|
+
const lenient = Number(map.lenient);
|
|
89
|
+
const sessionId = map.session;
|
|
90
|
+
const generatedAt = map.generatedAt;
|
|
91
|
+
if (status !== "passed" && status !== "warned" && status !== "failed") return null;
|
|
92
|
+
if (!Number.isFinite(strict) || !Number.isFinite(lenient)) return null;
|
|
93
|
+
if (!sessionId || !generatedAt) return null;
|
|
94
|
+
return { status, strict, lenient, sessionId, generatedAt };
|
|
95
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the harness PR comment subsystem.
|
|
3
|
+
*
|
|
4
|
+
* Pure data shapes only — no IO contracts live here. Keeping them in their own module
|
|
5
|
+
* means the render layer can be imported by tests without dragging the gh poster or env
|
|
6
|
+
* detection along.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { HarnessScore, HarnessValidateReport } from "../../types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Snapshot of a previous score, sourced from `score-history.jsonl`. Score-history v1
|
|
13
|
+
* carries only the top-level scalars — per-dimension breakdowns are not persisted — so the
|
|
14
|
+
* renderer can compute a banner Δ but must show "—" for dimension columns. The optional
|
|
15
|
+
* `dimensions` field is reserved so a future score-history schema change can widen this
|
|
16
|
+
* without touching call sites.
|
|
17
|
+
*/
|
|
18
|
+
export interface PrCommentPreviousScore {
|
|
19
|
+
recordedAt: string;
|
|
20
|
+
strict: number;
|
|
21
|
+
lenient: number;
|
|
22
|
+
dimensions?: HarnessScore["dimensions"];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Status banner derived from a validate report. */
|
|
26
|
+
export type PrCommentStatus = "passed" | "warned" | "failed";
|
|
27
|
+
|
|
28
|
+
/** Trend bucket (oldest-first slice from score-history.jsonl). */
|
|
29
|
+
export interface PrCommentTrendPoint {
|
|
30
|
+
ts: string;
|
|
31
|
+
strict: number;
|
|
32
|
+
lenient: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Per-dimension delta computed against the previous score. */
|
|
36
|
+
export interface PrCommentDimensionDelta {
|
|
37
|
+
name: HarnessScore["dimensions"][number]["name"];
|
|
38
|
+
/** Strict-score delta vs previous. 0 when no baseline. */
|
|
39
|
+
strict: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Inputs to the pure renderer. */
|
|
43
|
+
export interface RenderInput {
|
|
44
|
+
report: HarnessValidateReport;
|
|
45
|
+
/** Last score before the current one (drop the current entry). null on first run. */
|
|
46
|
+
previousScore: PrCommentPreviousScore | null;
|
|
47
|
+
/** Trend slice, oldest-first. May be empty. */
|
|
48
|
+
trend: readonly PrCommentTrendPoint[];
|
|
49
|
+
/** Score floor configured on the harness (from `HarnessHookConfig.score_floor`). */
|
|
50
|
+
scoreFloor: { strict: number; lenient: number };
|
|
51
|
+
sessionId: string;
|
|
52
|
+
/** Optional URL to the workflow run for the footer. */
|
|
53
|
+
runUrl?: string;
|
|
54
|
+
/** Optional URL to the raw validate-report.json artifact. */
|
|
55
|
+
reportArtifactUrl?: string;
|
|
56
|
+
/** "main@a1b2c3d" style label for the base ref in the summary line. */
|
|
57
|
+
baseRef?: string;
|
|
58
|
+
/** ISO timestamp used in the marker. Tests pass a fixed value. */
|
|
59
|
+
generatedAt: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Output of the pure renderer. */
|
|
63
|
+
export interface RenderResult {
|
|
64
|
+
/** GitHub-flavoured markdown. First line is exactly `marker`. */
|
|
65
|
+
body: string;
|
|
66
|
+
/** Single-line HTML comment containing every machine-parseable field. */
|
|
67
|
+
marker: string;
|
|
68
|
+
status: PrCommentStatus;
|
|
69
|
+
/** Strict-score delta vs previous. 0 when no baseline. */
|
|
70
|
+
scoreDelta: number;
|
|
71
|
+
/** Per-dimension deltas, parallel to `report.score.dimensions`. */
|
|
72
|
+
dimensionDeltas: readonly PrCommentDimensionDelta[];
|
|
73
|
+
}
|