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