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.
Files changed (84) hide show
  1. package/README.md +5 -6
  2. package/package.json +4 -2
  3. package/skills/harness/SKILL.md +1 -0
  4. package/src/bootstrap.ts +8 -133
  5. package/src/commands/optimize-context.ts +153 -16
  6. package/src/commands/runbook.ts +511 -0
  7. package/src/config/defaults.ts +5 -5
  8. package/src/config/loader.ts +1 -0
  9. package/src/config/schema.ts +2 -6
  10. package/src/context/rule-renderer.ts +274 -2
  11. package/src/context/runbook-extension-template.ts +193 -0
  12. package/src/context/startup-check.ts +197 -2
  13. package/src/context/startup-optimizer.ts +133 -10
  14. package/src/context-mode/knowledge/store.ts +381 -43
  15. package/src/context-mode/tools.ts +41 -3
  16. package/src/deps/registry.ts +1 -12
  17. package/src/fix-pr/assessment.ts +1 -0
  18. package/src/fix-pr/prompt-builder.ts +1 -0
  19. package/src/git/commit.ts +76 -18
  20. package/src/harness/command.ts +201 -12
  21. package/src/harness/default-agents/docs.md +39 -0
  22. package/src/harness/docs/config.ts +29 -0
  23. package/src/harness/docs/glob-match.ts +27 -0
  24. package/src/harness/docs/index-renderer.ts +82 -0
  25. package/src/harness/docs/provenance.ts +125 -0
  26. package/src/harness/docs/regen-decision.ts +167 -0
  27. package/src/harness/docs/representative-files.ts +175 -0
  28. package/src/harness/docs/source-hash.ts +106 -0
  29. package/src/harness/docs/validator.ts +233 -0
  30. package/src/harness/git-verification.ts +515 -0
  31. package/src/harness/git-verify-qa.ts +406 -0
  32. package/src/harness/hooks/layer-context-inject.ts +35 -1
  33. package/src/harness/hooks/register.ts +24 -3
  34. package/src/harness/pipeline.ts +37 -13
  35. package/src/harness/pr-comment/baseline.ts +105 -0
  36. package/src/harness/pr-comment/ci-env.ts +120 -0
  37. package/src/harness/pr-comment/gh-poster.ts +227 -0
  38. package/src/harness/pr-comment/handler.ts +198 -0
  39. package/src/harness/pr-comment/render.ts +297 -0
  40. package/src/harness/pr-comment/status.ts +95 -0
  41. package/src/harness/pr-comment/types.ts +73 -0
  42. package/src/harness/pr-comment/workflow-summary.ts +47 -0
  43. package/src/harness/project-paths.ts +95 -0
  44. package/src/harness/stages/design.ts +1 -0
  45. package/src/harness/stages/discover.ts +1 -13
  46. package/src/harness/stages/docs.ts +708 -0
  47. package/src/harness/stages/implement-apply.ts +934 -0
  48. package/src/harness/stages/implement.ts +64 -51
  49. package/src/harness/stages/plan.ts +25 -16
  50. package/src/harness/stages/validate.ts +478 -0
  51. package/src/harness/storage.ts +142 -0
  52. package/src/harness/tools.ts +130 -0
  53. package/src/mempalace/bridge.ts +207 -41
  54. package/src/mempalace/config.ts +10 -4
  55. package/src/mempalace/format.ts +122 -6
  56. package/src/mempalace/hooks.ts +204 -56
  57. package/src/mempalace/installer-helper.ts +18 -4
  58. package/src/mempalace/python/mempalace_bridge.py +128 -3
  59. package/src/mempalace/runtime.ts +53 -16
  60. package/src/mempalace/schema.ts +151 -30
  61. package/src/mempalace/session-summary.ts +5 -0
  62. package/src/mempalace/tool.ts +17 -4
  63. package/src/mempalace/upstream-limits.ts +69 -0
  64. package/src/planning/approval-flow.ts +25 -2
  65. package/src/planning/planning-ask-tool.ts +34 -4
  66. package/src/planning/system-prompt.ts +1 -1
  67. package/src/tool-catalog/active-tool-controller.ts +0 -22
  68. package/src/tool-catalog/active-tool-planner.ts +0 -26
  69. package/src/tool-catalog/tool-groups.ts +1 -9
  70. package/src/types.ts +127 -8
  71. package/src/ui-design/session.ts +114 -8
  72. package/src/utils/executable.ts +10 -1
  73. package/src/workspace/state-paths.ts +1 -1
  74. package/src/commands/mcp.ts +0 -814
  75. package/src/mcp/activation.ts +0 -77
  76. package/src/mcp/config.ts +0 -223
  77. package/src/mcp/docs.ts +0 -154
  78. package/src/mcp/gateway.ts +0 -103
  79. package/src/mcp/lifecycle.ts +0 -79
  80. package/src/mcp/manager-tool.ts +0 -104
  81. package/src/mcp/mcpc.ts +0 -113
  82. package/src/mcp/registry.ts +0 -98
  83. package/src/mcp/triggers.ts +0 -62
  84. package/src/mcp/types.ts +0 -95
@@ -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
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Fallback writer for `$GITHUB_STEP_SUMMARY`.
3
+ *
4
+ * GitHub Actions reads any markdown appended to the file at this env var and renders it
5
+ * on the workflow run summary page. We use it as a fail-open fallback when posting a PR
6
+ * comment is impossible (no auth, no `gh` CLI, no PR context).
7
+ *
8
+ * Never throws. Returns `{ ok: false }` quietly on IO error so the calling pipeline does
9
+ * not crash on a runner with an unwritable `$GITHUB_STEP_SUMMARY`.
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+
14
+ export interface WriteStepSummaryResult {
15
+ /** True when the body was appended (or no-op because env is unset). */
16
+ ok: boolean;
17
+ /** Resolved summary file path when appended. */
18
+ path?: string;
19
+ /** When ok=false, a short reason for diagnostics. */
20
+ reason?: string;
21
+ }
22
+
23
+ /**
24
+ * Append `body` to the workflow step summary file. When `$GITHUB_STEP_SUMMARY` is unset
25
+ * (local dev), this is a successful no-op.
26
+ */
27
+ export function writeStepSummary(
28
+ body: string,
29
+ env: NodeJS.ProcessEnv = process.env,
30
+ ): WriteStepSummaryResult {
31
+ const summaryPath = env.GITHUB_STEP_SUMMARY;
32
+ if (!summaryPath) {
33
+ return { ok: true };
34
+ }
35
+ try {
36
+ // The summary file is append-only between steps; we add a leading blank line so the
37
+ // harness section is separated from anything an earlier step contributed.
38
+ const payload = body.endsWith("\n") ? `\n${body}` : `\n${body}\n`;
39
+ fs.appendFileSync(summaryPath, payload);
40
+ return { ok: true, path: summaryPath };
41
+ } catch (error) {
42
+ return {
43
+ ok: false,
44
+ reason: error instanceof Error ? error.message : String(error),
45
+ };
46
+ }
47
+ }
@@ -52,6 +52,13 @@ export const HARNESS_IMPLEMENT_LOG_FILENAME = "implement-log.jsonl";
52
52
  export const HARNESS_PIPELINE_LOG_FILENAME = "pipeline-log.jsonl";
53
53
  export const HARNESS_RESEARCH_DIRNAME = "research";
54
54
 
55
+ /** Per-session staging directory for the docs stage. */
56
+ export const HARNESS_DOCS_STAGING_DIRNAME = "docs";
57
+ /** Layers subdirectory within the docs staging or repo-docs directory. */
58
+ export const HARNESS_DOCS_LAYERS_DIRNAME = "layers";
59
+ /** Index filename rendered after layer docs promote. */
60
+ export const HARNESS_DOCS_README_FILENAME = "README.md";
61
+
55
62
  /** Tier 1 / Tier 2 output paths in the repo. */
56
63
  export const HARNESS_AGENTS_MD_FILENAME = "AGENTS.md";
57
64
  export const HARNESS_DOCS_DIRNAME = "docs";
@@ -233,3 +240,91 @@ export function getHarnessGoldenPrinciplesPath(_paths: PlatformPaths, cwd: strin
233
240
  export function getHarnessFallowConfigPath(_paths: PlatformPaths, cwd: string): string {
234
241
  return path.join(cwd, HARNESS_FALLOW_CONFIG_FILENAME);
235
242
  }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Docs stage — staging + repo paths.
246
+ // ---------------------------------------------------------------------------
247
+ /**
248
+ * Strict whitelist for per-layer doc identifiers.
249
+ *
250
+ * Layer ids are user/model-controlled (set in `design-spec.json`) and feed into
251
+ * `<repo>/docs/layers/<id>.md` plus the per-session staging mirror. Without a strict
252
+ * boundary an id like `../golden-principles` would let the docs stage overwrite Tier 1
253
+ * docs (or escape `docs/layers/` entirely). Reject anything that isn't a safe filename
254
+ * stem: alnum, hyphen, underscore, and (non-leading, non-consecutive) dots.
255
+ */
256
+ const LAYER_ID_PATTERN = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/;
257
+
258
+ export function isSafeLayerId(layerId: string): boolean {
259
+ return (
260
+ typeof layerId === "string" &&
261
+ LAYER_ID_PATTERN.test(layerId) &&
262
+ !layerId.includes("..")
263
+ );
264
+ }
265
+
266
+ export function assertSafeLayerId(layerId: string): void {
267
+ if (!isSafeLayerId(layerId)) {
268
+ throw new Error(
269
+ `harness: invalid layer id ${JSON.stringify(layerId)} — expected [A-Za-z0-9._-]{1,64} with no path separators, leading dot, or '..'.`,
270
+ );
271
+ }
272
+ }
273
+
274
+
275
+ /**
276
+ * Per-session staging dir for the docs stage:
277
+ * `<projectRoot>/sessions/<sid>/docs/`. Subagents write `layers/<id>.md` here; the stage
278
+ * renders the index and atomically promotes both staging files to the repo.
279
+ */
280
+ export function getHarnessDocsStagingDir(
281
+ paths: PlatformPaths,
282
+ cwd: string,
283
+ sessionId: string,
284
+ ): string {
285
+ return path.join(getHarnessSessionDir(paths, cwd, sessionId), HARNESS_DOCS_STAGING_DIRNAME);
286
+ }
287
+
288
+ /** Per-session staging path for a single layer doc. */
289
+ export function getHarnessDocsStagingLayerPath(
290
+ paths: PlatformPaths,
291
+ cwd: string,
292
+ sessionId: string,
293
+ layerId: string,
294
+ ): string {
295
+ assertSafeLayerId(layerId);
296
+ return path.join(
297
+ getHarnessDocsStagingDir(paths, cwd, sessionId),
298
+ HARNESS_DOCS_LAYERS_DIRNAME,
299
+ `${layerId}.md`,
300
+ );
301
+ }
302
+
303
+ /** Per-session staging path for the rendered docs index. */
304
+ export function getHarnessDocsStagingReadmePath(
305
+ paths: PlatformPaths,
306
+ cwd: string,
307
+ sessionId: string,
308
+ ): string {
309
+ return path.join(getHarnessDocsStagingDir(paths, cwd, sessionId), HARNESS_DOCS_README_FILENAME);
310
+ }
311
+
312
+ /** Repo-root path for the rendered docs index: `<repo>/docs/README.md`. */
313
+ export function getHarnessRepoDocsReadmePath(_paths: PlatformPaths, cwd: string): string {
314
+ return path.join(cwd, HARNESS_DOCS_DIRNAME, HARNESS_DOCS_README_FILENAME);
315
+ }
316
+
317
+ /** Repo-root path for a single per-layer doc: `<repo>/docs/layers/<id>.md`. */
318
+ export function getHarnessRepoDocsLayerPath(
319
+ _paths: PlatformPaths,
320
+ cwd: string,
321
+ layerId: string,
322
+ ): string {
323
+ assertSafeLayerId(layerId);
324
+ return path.join(cwd, HARNESS_DOCS_DIRNAME, HARNESS_DOCS_LAYERS_DIRNAME, `${layerId}.md`);
325
+ }
326
+
327
+ /** Repo-root directory hosting the per-layer docs: `<repo>/docs/layers/`. */
328
+ export function getHarnessRepoDocsLayersDir(_paths: PlatformPaths, cwd: string): string {
329
+ return path.join(cwd, HARNESS_DOCS_DIRNAME, HARNESS_DOCS_LAYERS_DIRNAME);
330
+ }
@@ -61,6 +61,7 @@ export function defaultCiConfigFromDiscover(discover: HarnessDiscoverArtifact):
61
61
  trigger: { mode: "branches", branches: ["dev", "main"] },
62
62
  localCommand,
63
63
  workflowPath: ".github/workflows/harness-quality.yml",
64
+ prComment: { enabled: true, mode: "every-push" },
64
65
  };
65
66
  }
66
67
 
@@ -190,7 +190,6 @@ function detectOmpInfra(cwd: string): HarnessDiscoverArtifact["ompInfra"] {
190
190
  const hasSupipowers = existsSync(supipowersDir);
191
191
  const skills: string[] = [];
192
192
  const reviewAgents: string[] = [];
193
- const mcpServers: string[] = [];
194
193
  let plansCount = 0;
195
194
 
196
195
  // Skills are typically in skills/ at the repo root.
@@ -226,20 +225,9 @@ function detectOmpInfra(cwd: string): HarnessDiscoverArtifact["ompInfra"] {
226
225
  plansCount = 0;
227
226
  }
228
227
  }
229
- const mcpJson = path.join(supipowersDir, ".mcp.json");
230
- if (existsSync(mcpJson)) {
231
- try {
232
- const parsed = JSON.parse(fs.readFileSync(mcpJson, "utf8")) as {
233
- servers?: Record<string, unknown>;
234
- };
235
- if (parsed.servers) mcpServers.push(...Object.keys(parsed.servers));
236
- } catch {
237
- // ignore
238
- }
239
- }
240
228
  }
241
229
 
242
- return { hasSupipowers, skills, reviewAgents, mcpServers, plansCount };
230
+ return { hasSupipowers, skills, reviewAgents, plansCount };
243
231
  }
244
232
 
245
233
  function detectFrameworks(cwd: string): string[] {