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,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load the trend baseline from `score-history.jsonl`.
|
|
3
|
+
*
|
|
4
|
+
* The validate stage appends one record per run to this file. We split that history
|
|
5
|
+
* into:
|
|
6
|
+
* - `previousScore`: the most recent prior entry (so we can compute a delta vs the
|
|
7
|
+
* score we just wrote), or null when there is nothing to compare against;
|
|
8
|
+
* - `trend`: the last N entries oldest-first, for the inline sparkline.
|
|
9
|
+
*
|
|
10
|
+
* Score-history v1 records are `{ recordedAt, sessionId, strict, lenient }` (see
|
|
11
|
+
* `src/harness/stages/validate.ts`). Per-dimension breakdowns are NOT persisted, so we
|
|
12
|
+
* surface them as `undefined` and the renderer shows "—" for the dimension Δ column.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { PlatformPaths } from "../../platform/types.js";
|
|
16
|
+
import type { UltraPlanStorageResult } from "../../types.js";
|
|
17
|
+
import { readJsonl } from "../storage.js";
|
|
18
|
+
import { getHarnessScoreHistoryPath } from "../project-paths.js";
|
|
19
|
+
import type { PrCommentPreviousScore, PrCommentTrendPoint } from "./types.js";
|
|
20
|
+
|
|
21
|
+
/** Raw score-history record as written by Validate. */
|
|
22
|
+
interface ScoreHistoryRecord {
|
|
23
|
+
recordedAt: string;
|
|
24
|
+
sessionId: string;
|
|
25
|
+
strict: number;
|
|
26
|
+
lenient: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Baseline {
|
|
30
|
+
/** Most recent prior entry. null when history is empty or has only one record. */
|
|
31
|
+
previousScore: PrCommentPreviousScore | null;
|
|
32
|
+
/** Last `limit` entries, oldest first. Empty when no history. */
|
|
33
|
+
trend: readonly PrCommentTrendPoint[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_TREND_LIMIT = 5;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read score-history.jsonl and split it into (previous, trend).
|
|
40
|
+
*
|
|
41
|
+
* `currentSessionId` is what just ran — we drop ALL trailing records that match it so we
|
|
42
|
+
* never compare a score against itself, even when validate is re-run for the same session.
|
|
43
|
+
*
|
|
44
|
+
* Returns an empty baseline (`previousScore: null`, `trend: []`) when the history file is
|
|
45
|
+
* missing or unreadable. We deliberately swallow IO errors here: a corrupted history file
|
|
46
|
+
* should degrade gracefully to "no baseline" rather than block PR comment generation.
|
|
47
|
+
*/
|
|
48
|
+
export function loadBaseline(
|
|
49
|
+
paths: PlatformPaths,
|
|
50
|
+
cwd: string,
|
|
51
|
+
options: { currentSessionId?: string; limit?: number } = {},
|
|
52
|
+
): Baseline {
|
|
53
|
+
const limit = options.limit ?? DEFAULT_TREND_LIMIT;
|
|
54
|
+
const result: UltraPlanStorageResult<ScoreHistoryRecord[]> = readJsonl<ScoreHistoryRecord>(
|
|
55
|
+
getHarnessScoreHistoryPath(paths, cwd),
|
|
56
|
+
);
|
|
57
|
+
if (!result.ok) {
|
|
58
|
+
return { previousScore: null, trend: [] };
|
|
59
|
+
}
|
|
60
|
+
const records = result.value.filter((record) => isWellFormed(record));
|
|
61
|
+
|
|
62
|
+
// Strip the trailing run(s) that belong to the current session so we compare against the
|
|
63
|
+
// PRIOR run. When currentSessionId is omitted (local dry-run with no session context),
|
|
64
|
+
// we treat the most recent record as the baseline.
|
|
65
|
+
let priorEnd = records.length;
|
|
66
|
+
if (options.currentSessionId) {
|
|
67
|
+
while (priorEnd > 0 && records[priorEnd - 1].sessionId === options.currentSessionId) {
|
|
68
|
+
priorEnd -= 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const previousRecord = priorEnd > 0 ? records[priorEnd - 1] : null;
|
|
73
|
+
const previousScore: PrCommentPreviousScore | null = previousRecord
|
|
74
|
+
? {
|
|
75
|
+
recordedAt: previousRecord.recordedAt,
|
|
76
|
+
strict: previousRecord.strict,
|
|
77
|
+
lenient: previousRecord.lenient,
|
|
78
|
+
}
|
|
79
|
+
: null;
|
|
80
|
+
|
|
81
|
+
// Trend is the last `limit` records oldest-first. We include the current run so the
|
|
82
|
+
// sparkline ends with the just-computed score; the renderer can choose whether to
|
|
83
|
+
// highlight it.
|
|
84
|
+
const trendSlice = records.slice(Math.max(0, records.length - limit));
|
|
85
|
+
const trend: PrCommentTrendPoint[] = trendSlice.map((record) => ({
|
|
86
|
+
ts: record.recordedAt,
|
|
87
|
+
strict: record.strict,
|
|
88
|
+
lenient: record.lenient,
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
return { previousScore, trend };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isWellFormed(record: unknown): record is ScoreHistoryRecord {
|
|
95
|
+
if (record === null || typeof record !== "object") return false;
|
|
96
|
+
const r = record as Record<string, unknown>;
|
|
97
|
+
return (
|
|
98
|
+
typeof r.recordedAt === "string" &&
|
|
99
|
+
typeof r.sessionId === "string" &&
|
|
100
|
+
typeof r.strict === "number" &&
|
|
101
|
+
typeof r.lenient === "number" &&
|
|
102
|
+
Number.isFinite(r.strict) &&
|
|
103
|
+
Number.isFinite(r.lenient)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions environment detection for the PR comment subcommand.
|
|
3
|
+
*
|
|
4
|
+
* The harness PR comment workflow runs in two contexts:
|
|
5
|
+
* - inside GitHub Actions on a `pull_request` event (real CI run), and
|
|
6
|
+
* - locally for `--dry-run` previews and ad-hoc testing.
|
|
7
|
+
*
|
|
8
|
+
* This module owns the detection of the former. It deliberately does no IO except reading
|
|
9
|
+
* a single event JSON file when `GITHUB_EVENT_PATH` is provided.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
|
|
14
|
+
export interface CiContext {
|
|
15
|
+
/** "owner/repo" — extracted from GITHUB_REPOSITORY or supplied via flag. */
|
|
16
|
+
repo: string;
|
|
17
|
+
/** PR number — from the event payload or the --pr flag. */
|
|
18
|
+
prNumber: number;
|
|
19
|
+
/** Optional run URL, used in the comment footer. */
|
|
20
|
+
runUrl?: string;
|
|
21
|
+
/** Optional base ref, e.g. "main@a1b2c3d", used in the summary line. */
|
|
22
|
+
baseRef?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Manual overrides parsed from CLI flags; flag values win over env. */
|
|
26
|
+
export interface CiContextOverrides {
|
|
27
|
+
repo?: string;
|
|
28
|
+
prNumber?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Detect the CI context from environment variables, applying optional overrides on top.
|
|
33
|
+
*
|
|
34
|
+
* Returns null when neither the env nor the overrides produce a complete `{repo, prNumber}`
|
|
35
|
+
* pair — that's how the handler decides to fall back to the workflow summary.
|
|
36
|
+
*/
|
|
37
|
+
export function detectCiContext(
|
|
38
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
39
|
+
overrides: CiContextOverrides = {},
|
|
40
|
+
): CiContext | null {
|
|
41
|
+
const repo = overrides.repo ?? env.GITHUB_REPOSITORY;
|
|
42
|
+
if (!repo || !/^[^/\s]+\/[^/\s]+$/.test(repo)) {
|
|
43
|
+
if (!repo) return null;
|
|
44
|
+
// Malformed repo string (e.g. missing slash). Return null rather than corrupting URLs.
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let prNumber = overrides.prNumber;
|
|
49
|
+
let baseRef: string | undefined;
|
|
50
|
+
if (prNumber === undefined) {
|
|
51
|
+
const fromEvent = readPullRequestFromEvent(env);
|
|
52
|
+
if (fromEvent) {
|
|
53
|
+
prNumber = fromEvent.prNumber;
|
|
54
|
+
baseRef = fromEvent.baseRef;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (prNumber === undefined || !Number.isFinite(prNumber) || prNumber <= 0) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const runUrl = buildRunUrl(env, repo);
|
|
62
|
+
const ctx: CiContext = { repo, prNumber };
|
|
63
|
+
if (runUrl) ctx.runUrl = runUrl;
|
|
64
|
+
if (baseRef) ctx.baseRef = baseRef;
|
|
65
|
+
return ctx;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface PullRequestEventFields {
|
|
69
|
+
prNumber: number;
|
|
70
|
+
baseRef?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readPullRequestFromEvent(env: NodeJS.ProcessEnv): PullRequestEventFields | null {
|
|
74
|
+
const eventPath = env.GITHUB_EVENT_PATH;
|
|
75
|
+
if (!eventPath) return null;
|
|
76
|
+
let raw: string;
|
|
77
|
+
try {
|
|
78
|
+
raw = fs.readFileSync(eventPath, "utf8");
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
let parsed: unknown;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (parsed === null || typeof parsed !== "object") return null;
|
|
89
|
+
const obj = parsed as Record<string, unknown>;
|
|
90
|
+
const pr = obj.pull_request;
|
|
91
|
+
if (pr === null || typeof pr !== "object") {
|
|
92
|
+
// Some events (issue_comment on a PR) carry `issue.pull_request` instead. We only
|
|
93
|
+
// support the `pull_request` event in v1; everything else returns null.
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const prRecord = pr as Record<string, unknown>;
|
|
97
|
+
const number = prRecord.number;
|
|
98
|
+
if (typeof number !== "number" || !Number.isFinite(number)) return null;
|
|
99
|
+
|
|
100
|
+
let baseRef: string | undefined;
|
|
101
|
+
const base = prRecord.base;
|
|
102
|
+
if (base && typeof base === "object") {
|
|
103
|
+
const baseRecord = base as Record<string, unknown>;
|
|
104
|
+
const ref = baseRecord.ref;
|
|
105
|
+
const sha = baseRecord.sha;
|
|
106
|
+
if (typeof ref === "string" && typeof sha === "string") {
|
|
107
|
+
baseRef = `${ref}@${sha.slice(0, 7)}`;
|
|
108
|
+
} else if (typeof ref === "string") {
|
|
109
|
+
baseRef = ref;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { prNumber: number, baseRef };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildRunUrl(env: NodeJS.ProcessEnv, repo: string): string | undefined {
|
|
116
|
+
const server = env.GITHUB_SERVER_URL;
|
|
117
|
+
const runId = env.GITHUB_RUN_ID;
|
|
118
|
+
if (!server || !runId) return undefined;
|
|
119
|
+
return `${server}/${repo}/actions/runs/${runId}`;
|
|
120
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gh` CLI wrapper for the harness PR sticky comment.
|
|
3
|
+
*
|
|
4
|
+
* Fail-open by design: every failure path returns a typed `PostOutcome` instead of
|
|
5
|
+
* throwing, so the caller can decide whether to surface a workflow-summary fallback. The
|
|
6
|
+
* pipeline never blocks on PR-comment posting.
|
|
7
|
+
*
|
|
8
|
+
* Pattern mirrors `src/fix-pr/fetch-comments.ts` and `src/release/channels/github.ts`:
|
|
9
|
+
* we never construct an Octokit client; `platform.exec("gh", [...])` is the only
|
|
10
|
+
* dependency.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Platform } from "../../platform/types.js";
|
|
14
|
+
import { parseMarker, STICKY_MARKER_PREFIX } from "./status.js";
|
|
15
|
+
import type { PrCommentStatus } from "./types.js";
|
|
16
|
+
|
|
17
|
+
/** Outcome of an upsert attempt. */
|
|
18
|
+
export type PostOutcome =
|
|
19
|
+
| { kind: "created"; commentId: number }
|
|
20
|
+
| { kind: "updated"; commentId: number }
|
|
21
|
+
| { kind: "unchanged"; commentId: number; reason: "status-unchanged" }
|
|
22
|
+
| { kind: "skipped"; reason: "no-auth" | "no-cli" | "no-pr-env" }
|
|
23
|
+
| { kind: "failed"; reason: string };
|
|
24
|
+
|
|
25
|
+
export interface PostStickyOptions {
|
|
26
|
+
repo: string;
|
|
27
|
+
prNumber: number;
|
|
28
|
+
cwd: string;
|
|
29
|
+
body: string;
|
|
30
|
+
mode: "every-push" | "on-status-change";
|
|
31
|
+
currentStatus: PrCommentStatus;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Idempotent upsert of the sticky comment.
|
|
36
|
+
*
|
|
37
|
+
* 1. Verify `gh` is installed and authenticated.
|
|
38
|
+
* 2. List PR comments; find the first whose body starts with the harness marker prefix.
|
|
39
|
+
* 3. When `mode === "on-status-change"`, parse the previous status; bail with `unchanged`
|
|
40
|
+
* when it matches `currentStatus`.
|
|
41
|
+
* 4. PATCH the existing comment, or POST a new one when nothing matched.
|
|
42
|
+
*/
|
|
43
|
+
export async function postStickyComment(
|
|
44
|
+
platform: Platform,
|
|
45
|
+
options: PostStickyOptions,
|
|
46
|
+
): Promise<PostOutcome> {
|
|
47
|
+
const { repo, prNumber, cwd, body, mode, currentStatus } = options;
|
|
48
|
+
|
|
49
|
+
const auth = await checkAuth(platform, cwd);
|
|
50
|
+
if (auth.kind !== "ok") return auth;
|
|
51
|
+
|
|
52
|
+
const existing = await findStickyComment(platform, repo, prNumber, cwd);
|
|
53
|
+
if (existing.kind === "failed") return existing;
|
|
54
|
+
|
|
55
|
+
if (existing.kind === "found") {
|
|
56
|
+
if (mode === "on-status-change") {
|
|
57
|
+
const parsed = parseMarker(existing.body);
|
|
58
|
+
if (parsed && parsed.status === currentStatus) {
|
|
59
|
+
return { kind: "unchanged", commentId: existing.id, reason: "status-unchanged" };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const patched = await patchComment(platform, repo, existing.id, body, cwd);
|
|
63
|
+
return patched;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// No sticky yet — create one.
|
|
67
|
+
return createComment(platform, repo, prNumber, body, cwd);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Internals
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
async function checkAuth(
|
|
75
|
+
platform: Platform,
|
|
76
|
+
cwd: string,
|
|
77
|
+
): Promise<{ kind: "ok" } | { kind: "skipped"; reason: "no-auth" | "no-cli" }> {
|
|
78
|
+
let result: Awaited<ReturnType<Platform["exec"]>>;
|
|
79
|
+
try {
|
|
80
|
+
result = await platform.exec("gh", ["auth", "status"], { cwd });
|
|
81
|
+
} catch {
|
|
82
|
+
// ENOENT (gh missing) or other spawn-time failure — treat as no-cli.
|
|
83
|
+
return { kind: "skipped", reason: "no-cli" };
|
|
84
|
+
}
|
|
85
|
+
if (result.code === 0) return { kind: "ok" };
|
|
86
|
+
return { kind: "skipped", reason: "no-auth" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type FindResult =
|
|
90
|
+
| { kind: "found"; id: number; body: string }
|
|
91
|
+
| { kind: "not-found" }
|
|
92
|
+
| { kind: "failed"; reason: string };
|
|
93
|
+
|
|
94
|
+
async function findStickyComment(
|
|
95
|
+
platform: Platform,
|
|
96
|
+
repo: string,
|
|
97
|
+
prNumber: number,
|
|
98
|
+
cwd: string,
|
|
99
|
+
): Promise<FindResult> {
|
|
100
|
+
let result: Awaited<ReturnType<Platform["exec"]>>;
|
|
101
|
+
try {
|
|
102
|
+
result = await platform.exec(
|
|
103
|
+
"gh",
|
|
104
|
+
[
|
|
105
|
+
"api",
|
|
106
|
+
"--paginate",
|
|
107
|
+
`repos/${repo}/issues/${prNumber}/comments`,
|
|
108
|
+
"--jq",
|
|
109
|
+
".[] | {id, body}",
|
|
110
|
+
],
|
|
111
|
+
{ cwd },
|
|
112
|
+
);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
|
|
115
|
+
}
|
|
116
|
+
if (result.code !== 0) {
|
|
117
|
+
return {
|
|
118
|
+
kind: "failed",
|
|
119
|
+
reason: result.stderr.trim() || `gh api exited with code ${result.code}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// `--jq '.[] | {id, body}'` emits one JSON object per line (NOT a JSON array). Crucially,
|
|
123
|
+
// bodies may contain newlines — the `--jq` filter on a *list* shouldn't, because jq's
|
|
124
|
+
// default emits compact JSON for objects, but we still parse defensively.
|
|
125
|
+
for (const line of splitJsonObjects(result.stdout)) {
|
|
126
|
+
let parsed: unknown;
|
|
127
|
+
try {
|
|
128
|
+
parsed = JSON.parse(line);
|
|
129
|
+
} catch {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (parsed === null || typeof parsed !== "object") continue;
|
|
133
|
+
const obj = parsed as { id?: unknown; body?: unknown };
|
|
134
|
+
if (typeof obj.id !== "number" || typeof obj.body !== "string") continue;
|
|
135
|
+
if (obj.body.startsWith(STICKY_MARKER_PREFIX)) {
|
|
136
|
+
return { kind: "found", id: obj.id, body: obj.body };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { kind: "not-found" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function createComment(
|
|
143
|
+
platform: Platform,
|
|
144
|
+
repo: string,
|
|
145
|
+
prNumber: number,
|
|
146
|
+
body: string,
|
|
147
|
+
cwd: string,
|
|
148
|
+
): Promise<PostOutcome> {
|
|
149
|
+
let result: Awaited<ReturnType<Platform["exec"]>>;
|
|
150
|
+
try {
|
|
151
|
+
result = await platform.exec(
|
|
152
|
+
"gh",
|
|
153
|
+
[
|
|
154
|
+
"api",
|
|
155
|
+
"-X", "POST",
|
|
156
|
+
`repos/${repo}/issues/${prNumber}/comments`,
|
|
157
|
+
"-f", `body=${body}`,
|
|
158
|
+
],
|
|
159
|
+
{ cwd },
|
|
160
|
+
);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
|
|
163
|
+
}
|
|
164
|
+
if (result.code !== 0) {
|
|
165
|
+
return { kind: "failed", reason: result.stderr.trim() || `gh api POST exited with code ${result.code}` };
|
|
166
|
+
}
|
|
167
|
+
const id = extractCommentId(result.stdout);
|
|
168
|
+
if (id === null) {
|
|
169
|
+
return { kind: "failed", reason: "gh api POST succeeded but response is missing comment id" };
|
|
170
|
+
}
|
|
171
|
+
return { kind: "created", commentId: id };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function patchComment(
|
|
175
|
+
platform: Platform,
|
|
176
|
+
repo: string,
|
|
177
|
+
commentId: number,
|
|
178
|
+
body: string,
|
|
179
|
+
cwd: string,
|
|
180
|
+
): Promise<PostOutcome> {
|
|
181
|
+
let result: Awaited<ReturnType<Platform["exec"]>>;
|
|
182
|
+
try {
|
|
183
|
+
result = await platform.exec(
|
|
184
|
+
"gh",
|
|
185
|
+
[
|
|
186
|
+
"api",
|
|
187
|
+
"-X", "PATCH",
|
|
188
|
+
`repos/${repo}/issues/comments/${commentId}`,
|
|
189
|
+
"-f", `body=${body}`,
|
|
190
|
+
],
|
|
191
|
+
{ cwd },
|
|
192
|
+
);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
|
|
195
|
+
}
|
|
196
|
+
if (result.code !== 0) {
|
|
197
|
+
return { kind: "failed", reason: result.stderr.trim() || `gh api PATCH exited with code ${result.code}` };
|
|
198
|
+
}
|
|
199
|
+
return { kind: "updated", commentId };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function extractCommentId(stdout: string): number | null {
|
|
203
|
+
try {
|
|
204
|
+
const parsed = JSON.parse(stdout);
|
|
205
|
+
if (parsed && typeof parsed === "object" && typeof (parsed as { id?: unknown }).id === "number") {
|
|
206
|
+
return (parsed as { id: number }).id;
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
// Fall through to regex scan; gh api can be configured with --jq for partial outputs.
|
|
210
|
+
}
|
|
211
|
+
const match = /"id"\s*:\s*(\d+)/.exec(stdout);
|
|
212
|
+
return match ? Number(match[1]) : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Split jq stream output into individual JSON object strings. jq's stream mode separates
|
|
217
|
+
* objects with a single newline, but body fields may contain unescaped newlines when the
|
|
218
|
+
* comment uses raw markdown. We rely on `JSON.parse` to validate each candidate and fall
|
|
219
|
+
* back to a line-based split.
|
|
220
|
+
*/
|
|
221
|
+
function splitJsonObjects(raw: string): string[] {
|
|
222
|
+
const trimmed = raw.trim();
|
|
223
|
+
if (trimmed.length === 0) return [];
|
|
224
|
+
// Fast path: each line is its own object (the common case for `--jq '.[] | {id, body}'`).
|
|
225
|
+
const lines = trimmed.split(/\n(?=\{)/).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
226
|
+
return lines;
|
|
227
|
+
}
|
|
@@ -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;
|