infraweaver 0.3.7 → 0.3.9

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 (175) hide show
  1. package/README.md +2 -2
  2. package/dist/agents/claude.d.ts +1 -1
  3. package/dist/agents/claudePretoolGate.d.ts +0 -36
  4. package/dist/agents/toolPlaneWatch.d.ts +31 -0
  5. package/dist/cli.mjs +31908 -18736
  6. package/dist/external.d.ts +2 -1
  7. package/dist/index.js +31992 -18749
  8. package/dist/internal.js +133 -52
  9. package/dist/mcp/baseBranch.d.ts +41 -0
  10. package/dist/mcp/changeSummary.d.ts +1 -1
  11. package/dist/mcp/findingAudit.d.ts +2 -0
  12. package/dist/mcp/guardrails.d.ts +38 -0
  13. package/dist/mcp/localContext.d.ts +1 -1
  14. package/dist/mcp/moduleExtraction.d.ts +5 -0
  15. package/dist/mcp/pr.d.ts +0 -31
  16. package/dist/mcp/reproveSuggestions.d.ts +27 -0
  17. package/dist/mcp/review.d.ts +126 -2
  18. package/dist/mcp/reviewCommentLimit.d.ts +17 -0
  19. package/dist/mcp/reviewComments.d.ts +9 -5
  20. package/dist/mcp/reviewDedup.d.ts +1 -0
  21. package/dist/mcp/reviewMarkers.d.ts +10 -0
  22. package/dist/mcp/reviewProvenance.d.ts +20 -6
  23. package/dist/mcp/shared.d.ts +15 -0
  24. package/dist/mcp/suggestionProver.d.ts +3 -2
  25. package/dist/mcp/terraform/checkovSeverities.d.ts +7 -0
  26. package/dist/mcp/terraform/consolidation.d.ts +26 -0
  27. package/dist/mcp/terraform/cost.d.ts +2 -2
  28. package/dist/mcp/terraform/currency.d.ts +11 -0
  29. package/dist/mcp/terraform/deltaSummary.d.ts +11 -1
  30. package/dist/mcp/terraform/emittedArtefacts.d.ts +11 -0
  31. package/dist/mcp/terraform/evidence.d.ts +10 -0
  32. package/dist/mcp/terraform/gateScan.d.ts +2 -16
  33. package/dist/mcp/terraform/hcl.d.ts +15 -0
  34. package/dist/mcp/terraform/initArtefacts.d.ts +21 -0
  35. package/dist/mcp/terraform/localModules.d.ts +29 -0
  36. package/dist/mcp/terraform/moduleFindings.d.ts +38 -0
  37. package/dist/mcp/terraform/refactor/attribution.d.ts +1 -1
  38. package/dist/mcp/terraform/refactor/equivalence.d.ts +7 -0
  39. package/dist/mcp/terraform/refactor/expressions.d.ts +62 -0
  40. package/dist/mcp/terraform/refactor/references.d.ts +15 -0
  41. package/dist/mcp/terraform/refactor/resources.d.ts +11 -0
  42. package/dist/mcp/terraform/refactor/substitution.d.ts +55 -37
  43. package/dist/mcp/terraform/scanDelta.d.ts +28 -0
  44. package/dist/mcp/terraform/scanSession.d.ts +13 -0
  45. package/dist/mcp/terraform/scanners.d.ts +67 -18
  46. package/dist/mcp/terraform/secretScan.d.ts +19 -0
  47. package/dist/mcp/terraform/standardsReport.d.ts +1 -1
  48. package/dist/mcp/terraform/tools/plan.d.ts +1 -1
  49. package/dist/mcp/terraform/tools/scan.d.ts +7 -7
  50. package/dist/mcp/terraform/tools/validate.d.ts +7 -2
  51. package/dist/mcp/terraform/tools/verifyRemediation.d.ts +1 -1
  52. package/dist/mcp/terraform/treeLayout.d.ts +57 -0
  53. package/dist/mcp/terraform/types.d.ts +1 -1
  54. package/dist/phases/applyPlan.d.ts +0 -2
  55. package/dist/toolState.d.ts +3 -0
  56. package/dist/utils/agent/codexOAuth.d.ts +1 -1
  57. package/dist/utils/cli.d.ts +1 -7
  58. package/dist/utils/cloud/cloudReport.d.ts +1 -1
  59. package/dist/utils/config/baseRefConfig.d.ts +1 -1
  60. package/dist/utils/config/infraweaverConfig.d.ts +1 -1
  61. package/dist/utils/config/payload.d.ts +5 -0
  62. package/dist/utils/markdownTable.d.ts +6 -0
  63. package/dist/utils/setup/toolLicensing.d.ts +1 -1
  64. package/dist/utils/unifiedDiff.d.ts +69 -0
  65. package/package.json +34 -28
  66. package/src/agents/claude.ts +48 -13
  67. package/src/agents/claudePretoolGate.ts +9 -17
  68. package/src/agents/sessionLabeler.ts +3 -3
  69. package/src/agents/toolPlaneWatch.ts +71 -0
  70. package/src/external.ts +3 -1
  71. package/src/mcp/assess.ts +8 -8
  72. package/src/mcp/baseBranch.ts +96 -0
  73. package/src/mcp/changeSummary.ts +10 -11
  74. package/src/mcp/checkSuite.ts +7 -10
  75. package/src/mcp/checkout.ts +13 -40
  76. package/src/mcp/crosswalk.ts +5 -0
  77. package/src/mcp/findingAudit.ts +1 -1
  78. package/src/mcp/git.ts +44 -2
  79. package/src/mcp/guardrails.ts +188 -36
  80. package/src/mcp/localContext.ts +2 -0
  81. package/src/mcp/localServer.ts +1 -0
  82. package/src/mcp/moduleExtraction.ts +17 -5
  83. package/src/mcp/moduleTests.ts +9 -2
  84. package/src/mcp/modules.ts +10 -7
  85. package/src/mcp/pr.ts +17 -65
  86. package/src/mcp/reproveSuggestions.ts +86 -0
  87. package/src/mcp/review.ts +249 -75
  88. package/src/mcp/reviewAnchor.ts +11 -23
  89. package/src/mcp/reviewCommentLimit.ts +79 -0
  90. package/src/mcp/reviewComments.ts +34 -81
  91. package/src/mcp/reviewDedup.ts +1 -1
  92. package/src/mcp/reviewMarkers.ts +25 -2
  93. package/src/mcp/reviewProvenance.ts +56 -21
  94. package/src/mcp/roots.ts +3 -2
  95. package/src/mcp/server.ts +2 -1
  96. package/src/mcp/shared.ts +40 -1
  97. package/src/mcp/shell.ts +27 -28
  98. package/src/mcp/suggestionProver.ts +8 -4
  99. package/src/mcp/terraform/checkovSeverities.ts +1156 -0
  100. package/src/mcp/terraform/concernResult.ts +2 -1
  101. package/src/mcp/terraform/consolidation.ts +232 -19
  102. package/src/mcp/terraform/cost.ts +16 -7
  103. package/src/mcp/terraform/cspm.ts +8 -4
  104. package/src/mcp/terraform/currency.ts +28 -12
  105. package/src/mcp/terraform/decisions.ts +1 -1
  106. package/src/mcp/terraform/deltaSummary.ts +50 -1
  107. package/src/mcp/terraform/emittedArtefacts.ts +63 -0
  108. package/src/mcp/terraform/evidence.ts +23 -3
  109. package/src/mcp/terraform/gateScan.ts +191 -128
  110. package/src/mcp/terraform/hcl.ts +27 -2
  111. package/src/mcp/terraform/idleFloor.ts +2 -1
  112. package/src/mcp/terraform/initArtefacts.ts +76 -0
  113. package/src/mcp/terraform/localModules.ts +112 -0
  114. package/src/mcp/terraform/moduleDocs.ts +2 -5
  115. package/src/mcp/terraform/moduleFindings.ts +102 -0
  116. package/src/mcp/terraform/moduleVersionConstraints.ts +1 -1
  117. package/src/mcp/terraform/planPairs.ts +2 -20
  118. package/src/mcp/terraform/policyGate.ts +0 -15
  119. package/src/mcp/terraform/refactor/attribution.ts +39 -5
  120. package/src/mcp/terraform/refactor/equivalence.ts +49 -10
  121. package/src/mcp/terraform/refactor/expressions.ts +323 -0
  122. package/src/mcp/terraform/refactor/references.ts +101 -0
  123. package/src/mcp/terraform/refactor/resources.ts +79 -7
  124. package/src/mcp/terraform/refactor/substitution.ts +852 -256
  125. package/src/mcp/terraform/scanDelta.ts +41 -0
  126. package/src/mcp/terraform/scanSession.ts +30 -0
  127. package/src/mcp/terraform/scannerCache.ts +4 -0
  128. package/src/mcp/terraform/scanners.ts +553 -233
  129. package/src/mcp/terraform/secretScan.ts +131 -0
  130. package/src/mcp/terraform/standardsReport.ts +14 -13
  131. package/src/mcp/terraform/tools/consolidationCandidates.ts +3 -0
  132. package/src/mcp/terraform/tools/emitEvidence.ts +13 -5
  133. package/src/mcp/terraform/tools/emitOscal.ts +3 -2
  134. package/src/mcp/terraform/tools/emitVex.ts +10 -4
  135. package/src/mcp/terraform/tools/equivalenceCheck.ts +139 -21
  136. package/src/mcp/terraform/tools/infracostDiff.ts +2 -2
  137. package/src/mcp/terraform/tools/plan.ts +30 -17
  138. package/src/mcp/terraform/tools/reviewScanDelta.ts +3 -0
  139. package/src/mcp/terraform/tools/scan.ts +5 -3
  140. package/src/mcp/terraform/tools/standardsReport.ts +1 -1
  141. package/src/mcp/terraform/tools/validate.ts +20 -5
  142. package/src/mcp/terraform/tools/verifyRemediation.ts +14 -7
  143. package/src/mcp/terraform/tools/versionCurrency.ts +3 -1
  144. package/src/mcp/terraform/treeLayout.ts +378 -0
  145. package/src/mcp/terraform/types.ts +4 -1
  146. package/src/mcp/terraform/versionRequirements.ts +11 -2
  147. package/src/modes/__snapshots__/assemblePrompt.test.ts.snap +2 -0
  148. package/src/modes/modernize-deprecated.ts +2 -2
  149. package/src/modes/refactor.ts +9 -9
  150. package/src/modes/remediate.ts +1 -1
  151. package/src/modes/terraform-code-review.ts +7 -3
  152. package/src/modes/update-dependencies.ts +6 -6
  153. package/src/phases/applyPlan.ts +33 -14
  154. package/src/phases/applySuggestions.ts +2 -2
  155. package/src/phases/runAgentWithWatchdogs.ts +2 -1
  156. package/src/toolState.ts +7 -0
  157. package/src/utils/agent/codexOAuth.ts +7 -7
  158. package/src/utils/agent/openCodeModels.ts +4 -6
  159. package/src/utils/cli.ts +1 -31
  160. package/src/utils/cloud/runContext.ts +1 -8
  161. package/src/utils/config/baseRefConfig.ts +6 -0
  162. package/src/utils/config/infraweaverConfig.ts +2 -0
  163. package/src/utils/config/payload.ts +41 -0
  164. package/src/utils/github/assets.ts +1 -6
  165. package/src/utils/github/github.ts +42 -150
  166. package/src/utils/github/token.ts +6 -11
  167. package/src/utils/log.ts +4 -5
  168. package/src/utils/markdownTable.ts +11 -0
  169. package/src/utils/prompt/changeImpact.ts +18 -26
  170. package/src/utils/prompt/instructions.ts +1 -1
  171. package/src/utils/prompt/promptDirectives.ts +1 -1
  172. package/src/utils/prompt/toolSelection.ts +11 -2
  173. package/src/utils/setup/toolLicensing.ts +7 -0
  174. package/src/utils/telemetry.ts +2 -1
  175. package/src/utils/unifiedDiff.ts +120 -0
@@ -0,0 +1,71 @@
1
+ import { formatMcpToolRef, infraweaverMcpName } from "#app/external";
2
+
3
+ /**
4
+ * Notices when the agent has lost the infraweaver tool server, so the run stops
5
+ * instead of spending its budget without it.
6
+ *
7
+ * Every deliverable — the push, the pull request, the review, the final
8
+ * progress report — goes through that server. Once Claude Code drops it, each
9
+ * call fails at once with "not connected", and a model told to keep trying
10
+ * retries in a tight loop until the token quota ends the run: a refactor did
11
+ * exactly that for 41 minutes and 8.5M tokens.
12
+ *
13
+ * One lost call is not enough to stop on: Claude Code reconnects, and a call
14
+ * that lands mid-reconnect fails the same way. The run stops only after
15
+ * {@link LOST_CALLS} such failures in a row spanning at least
16
+ * {@link LOST_WINDOW_MS}, with no infraweaver call succeeding in between.
17
+ */
18
+ export const LOST_CALLS = 3;
19
+ export const LOST_WINDOW_MS = 30_000;
20
+
21
+ /** Claude Code's wordings for a call to a server it no longer has. */
22
+ const LOST = new RegExp(
23
+ [
24
+ `MCP server ["']${infraweaverMcpName}["'] is not connected`,
25
+ `MCP server ["']${infraweaverMcpName}["'] has disconnected`,
26
+ `MCP server ["']${infraweaverMcpName}["'] transport dropped mid-call`,
27
+ `MCP tool call for ["']${infraweaverMcpName}["'] timed out connecting`,
28
+ ].join("|"),
29
+ );
30
+
31
+ const TOOL_PREFIX = formatMcpToolRef("claude", "");
32
+
33
+ export class ToolPlaneLostError extends Error {
34
+ constructor(calls: number, seconds: number) {
35
+ super(
36
+ `the agent lost the ${infraweaverMcpName} tool server: ${calls} calls in a row over ${seconds}s ` +
37
+ "failed because it was not connected, so the run was stopped rather than continuing " +
38
+ "without the tools that deliver its result",
39
+ );
40
+ this.name = "ToolPlaneLostError";
41
+ }
42
+ }
43
+
44
+ export interface ToolResult {
45
+ /** the tool that was called, when known. */
46
+ toolName: string | undefined;
47
+ isError: boolean;
48
+ text: string;
49
+ }
50
+
51
+ export function createToolPlaneWatch(now: () => number = Date.now) {
52
+ let streak = 0;
53
+ let since = 0;
54
+ return {
55
+ /** Record one tool result; returns the error to stop with once the server is lost. */
56
+ observe(result: ToolResult): ToolPlaneLostError | null {
57
+ if (result.isError && LOST.test(result.text)) {
58
+ if (streak === 0) since = now();
59
+ streak += 1;
60
+ const elapsed = now() - since;
61
+ return streak >= LOST_CALLS && elapsed >= LOST_WINDOW_MS
62
+ ? new ToolPlaneLostError(streak, Math.round(elapsed / 1000))
63
+ : null;
64
+ }
65
+ if (!result.isError && result.toolName?.startsWith(TOOL_PREFIX)) streak = 0;
66
+ return null;
67
+ },
68
+ };
69
+ }
70
+
71
+ export type ToolPlaneWatch = ReturnType<typeof createToolPlaneWatch>;
package/src/external.ts CHANGED
@@ -4,7 +4,9 @@
4
4
  * Other files in action/ re-export from this file for backward compatibility.
5
5
  */
6
6
 
7
- import type { PullRequest } from "@octokit/webhooks-types";
7
+ import type { components } from "@octokit/openapi-webhooks-types";
8
+
9
+ type PullRequest = components["schemas"]["pull-request"];
8
10
 
9
11
  // mcp name constant
10
12
  export const infraweaverMcpName = "infraweaver";
package/src/mcp/assess.ts CHANGED
@@ -20,9 +20,10 @@ import {
20
20
  languageOfConcern,
21
21
  resolveLanguageScope,
22
22
  } from "#app/mcp/terraform/iacLanguages";
23
+ import { count } from "#app/mcp/terraform/prose";
23
24
  import { runScannersCached } from "#app/mcp/terraform/scannerCache";
24
25
  import { attachSourceSnippets } from "#app/mcp/terraform/scanners";
25
- import { scanSessionOf } from "#app/mcp/terraform/scanSession";
26
+ import { recordScanConcerns, scanSessionOf } from "#app/mcp/terraform/scanSession";
26
27
  import { suppressConcerns } from "#app/mcp/terraform/suppressions";
27
28
  import {
28
29
  type Concern,
@@ -290,21 +291,20 @@ const BANNER_RECONCILE =
290
291
  */
291
292
  function renderSeverityBanner(s: AssessmentScorecard): string {
292
293
  const highBand = s.by_severity.critical + s.by_severity.high;
293
- const plural = (n: number) => (n === 1 ? "" : "s");
294
294
  switch (s.posture) {
295
295
  case "clean":
296
296
  return "> [!NOTE]\n> ✅ **0 concerns found** at the configured threshold.";
297
297
  case "advisory":
298
298
  return (
299
299
  "> [!WARNING]\n" +
300
- `> ⚠️ **${s.total} concern${plural(s.total)} found** — none critical/high. ` +
300
+ `> ⚠️ **${count(s.total, "concern")} found** — none critical/high. ` +
301
301
  "Plan to address them.\n>\n" +
302
302
  `> ${BANNER_RECONCILE}`
303
303
  );
304
304
  default:
305
305
  return (
306
306
  "> [!CAUTION]\n" +
307
- `> 🚨 **${highBand} critical/high concern${plural(highBand)} found** ` +
307
+ `> 🚨 **${count(highBand, "critical/high concern")} found** ` +
308
308
  `(${s.total} open in total). Review before relying on this Terraform.\n>\n` +
309
309
  `> ${BANNER_RECONCILE}`
310
310
  );
@@ -449,9 +449,7 @@ export function renderAssessmentMarkdown(
449
449
  .filter((sev) => s.by_severity[sev] > 0)
450
450
  .map((sev) => `\`${sev}: ${s.by_severity[sev]}\``);
451
451
  lines.push(
452
- `**${s.total} concern${s.total === 1 ? "" : "s"}** — ${
453
- sevCells.length ? sevCells.join(" · ") : "none"
454
- }`,
452
+ `**${count(s.total, "concern")}** — ${sevCells.length ? sevCells.join(" · ") : "none"}`,
455
453
  );
456
454
  if (ctx.delta) {
457
455
  lines.push("");
@@ -624,6 +622,8 @@ export async function runAssessmentPipeline(
624
622
  // the SAME threshold everywhere a scan happens. A verifier running on a
625
623
  // different one would report a finding resolved because the knob moved.
626
624
  repeatedLiteralThreshold: ctx.payload.repeatedLiteralThreshold,
625
+ // and the same variable files, which decide what trivy and checkov see
626
+ varFiles: ctx.payload.varFiles,
627
627
  });
628
628
  // Re-check the scope against what ACTUALLY ran: a language whose only covering
629
629
  // scanner was skipped/disabled this run must report as a coverage gap, never
@@ -716,7 +716,7 @@ export function TerraformAssessTool(ctx: LocalToolContext) {
716
716
  // so finalizeSuccessRun's emitFindingsOutputs + reportToCloud fire — assess IS
717
717
  // the report path, but lastScanConcerns was only ever set by terraform_scan
718
718
  // (Remediate), so Cloud reporting silently no-opped in Assess.
719
- scanSessionOf(ctx.toolState).lastScanConcerns = concerns;
719
+ recordScanConcerns(scanSessionOf(ctx.toolState), concerns);
720
720
  const markdown = renderAssessmentMarkdown(scorecard, { delta });
721
721
 
722
722
  const ran = outcomes.filter((o) => o.ran).map((o) => o.source);
@@ -0,0 +1,96 @@
1
+ import { $ } from "@infraweaver-io/sandbox/shell";
2
+ import type { ToolState } from "#app/toolState";
3
+ import type { RunContextData } from "#app/utils/cloud/runContextData";
4
+ import type { ResolvedPayload } from "#app/utils/config/payload";
5
+
6
+ /**
7
+ * The branch a run's change is proposed into. Its own module because three
8
+ * places need the same answer and must never disagree: the PR tools (where the
9
+ * PR goes), `terraform_change_summary` (what the change is measured against)
10
+ * and the push guardrail that refuses a change an open PR already proposes.
11
+ */
12
+
13
+ /**
14
+ * The slice `resolveBaseBranch` reads: the declared base-branch input, the
15
+ * branch the run started on and the repository's default branch.
16
+ */
17
+ export interface BaseBranchContext {
18
+ payload: ResolvedPayload;
19
+ repo: RunContextData["repo"];
20
+ toolState: Pick<ToolState, "initialHead">;
21
+ }
22
+
23
+ /**
24
+ * Pure base-branch picker. Precedence: an explicit `base_branch` declaration
25
+ * always wins; then the branch the run started on, so a run dispatched on
26
+ * `develop` proposes into `develop`; then the repository's default branch
27
+ * (GitHub reports it — normally `main`, sometimes `master`); otherwise prefer
28
+ * `main`, then `master`; final fallback `main`. Split out from git/ctx so it's
29
+ * exhaustively testable.
30
+ */
31
+ export function pickBaseBranch(opts: {
32
+ declared?: string | undefined;
33
+ startBranch?: string | undefined;
34
+ defaultBranch?: string | undefined;
35
+ mainExists: boolean;
36
+ masterExists: boolean;
37
+ }): string {
38
+ if (opts.declared) return opts.declared;
39
+ if (opts.startBranch) return opts.startBranch;
40
+ if (opts.defaultBranch) return opts.defaultBranch;
41
+ if (opts.mainExists) return "main";
42
+ if (opts.masterExists) return "master";
43
+ return "main";
44
+ }
45
+
46
+ /** true when `branch` exists as a remote-tracking or local ref. */
47
+ function branchExists(branch: string): boolean {
48
+ for (const ref of [`refs/remotes/origin/${branch}`, `refs/heads/${branch}`]) {
49
+ try {
50
+ $("git", ["rev-parse", "--verify", "--quiet", ref], { log: false });
51
+ return true;
52
+ } catch {
53
+ // ref absent — try the next form
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+
59
+ /**
60
+ * The branch the run started on, while HEAD is somewhere else. A run that is
61
+ * still on its starting branch is proposing or summarising that branch itself,
62
+ * so it cannot be the base; the run started detached (a pull request's merge
63
+ * ref) has no branch to offer.
64
+ */
65
+ function startBranchOf(ctx: BaseBranchContext): string | undefined {
66
+ const head = ctx.toolState.initialHead;
67
+ if (head?.kind !== "branch") return undefined;
68
+ let current: string;
69
+ try {
70
+ current = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ return current === head.name ? undefined : head.name;
75
+ }
76
+
77
+ /**
78
+ * Deterministically resolve the branch a PR targets, so the base is never left
79
+ * to the agent's guess: the `base_branch` input, else the branch the run
80
+ * started on, else the repository's default branch (`main`, or `master`). Git
81
+ * is only probed for main/master in the last-resort case where none of those is
82
+ * available (essentially never).
83
+ */
84
+ export function resolveBaseBranch(ctx: BaseBranchContext): string {
85
+ const declared = ctx.payload.baseBranch?.trim() || undefined;
86
+ const startBranch = declared ? undefined : startBranchOf(ctx);
87
+ const defaultBranch = ctx.repo.data.default_branch?.trim() || undefined;
88
+ const needsProbe = !declared && !startBranch && !defaultBranch;
89
+ return pickBaseBranch({
90
+ declared,
91
+ startBranch,
92
+ defaultBranch,
93
+ mainExists: needsProbe && branchExists("main"),
94
+ masterExists: needsProbe && branchExists("master"),
95
+ });
96
+ }
@@ -1,10 +1,11 @@
1
1
  import { $ } from "@infraweaver-io/sandbox/shell";
2
2
  import { type } from "arktype";
3
- import type { BaseBranchContext } from "#app/mcp/pr";
4
- import { resolveBaseBranch } from "#app/mcp/pr";
3
+ import type { BaseBranchContext } from "#app/mcp/baseBranch";
4
+ import { resolveBaseBranch } from "#app/mcp/baseBranch";
5
5
  import { execute, tool, toolOk } from "#app/mcp/shared";
6
6
  import { skipResult } from "#app/mcp/terraform/types";
7
7
  import { log } from "#app/utils/cli";
8
+ import { parseDiffLines } from "#app/utils/unifiedDiff";
8
9
 
9
10
  /**
10
11
  * AI PR summaries — the deterministic Terraform-change anchor. A PR summary
@@ -71,19 +72,17 @@ export function summarizeTerraformResourceDiff(diff: string): TerraformChangeSum
71
72
  const files = new Set<string>();
72
73
  let file = "";
73
74
  let inTf = false;
74
- for (const raw of diff.split("\n")) {
75
- if (raw.startsWith("+++ ")) {
76
- const path = raw.slice(4).trim().replace(/^b\//, "");
77
- file = path === "/dev/null" ? "" : path;
75
+ for (const line of parseDiffLines(diff)) {
76
+ if (line.kind === "file") {
77
+ file = line.path === "/dev/null" ? "" : line.path;
78
78
  inTf = !!file && isTfFile(file);
79
79
  continue;
80
80
  }
81
- if (raw.startsWith("--- ") || raw.startsWith("diff --git") || raw.startsWith("@@")) continue;
82
81
  if (!inTf) continue;
83
- if (raw.startsWith("+") || raw.startsWith("-")) {
82
+ if (line.kind === "add" || line.kind === "remove") {
84
83
  files.add(file);
85
- const addr = parseBlockAddress(raw.slice(1));
86
- if (addr) (raw.startsWith("+") ? added : removed).add(addr);
84
+ const addr = parseBlockAddress(line.text);
85
+ if (addr) (line.kind === "add" ? added : removed).add(addr);
87
86
  }
88
87
  }
89
88
  const sort = (s: Set<string>): string[] => [...s].sort();
@@ -97,7 +96,7 @@ export function summarizeTerraformResourceDiff(diff: string): TerraformChangeSum
97
96
 
98
97
  const TerraformChangeSummaryParams = type({
99
98
  "base?": type.string.describe(
100
- "base branch to diff against (default: the run's resolved base branch — main/master or the base_branch input).",
99
+ "base branch to diff against (default: the run's resolved base branch — the base_branch input, else the branch the run started on, else main/master).",
101
100
  ),
102
101
  });
103
102
 
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { stripVTControlCharacters } from "node:util";
3
4
  import { type } from "arktype";
4
5
  import type { GitHubToolContext } from "#app/mcp/capabilityContext";
5
6
  import { execute, tool } from "#app/mcp/shared";
@@ -29,8 +30,7 @@ type LogAnalysis = {
29
30
  };
30
31
 
31
32
  export function analyzeLog(logs: string, excerptLines = 80): LogAnalysis {
32
- // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes use control chars
33
- const clean = logs.replace(/\x1b\[[0-9;]*m/g, "");
33
+ const clean = stripVTControlCharacters(logs);
34
34
  const lines = clean.split("\n");
35
35
  const totalLines = lines.length;
36
36
 
@@ -192,19 +192,16 @@ export function GetCheckSuiteLogsTool(ctx: GitHubToolContext) {
192
192
  failedJobs,
193
193
  LOG_FETCH_CONCURRENCY,
194
194
  async (job): Promise<JobLogResult> => {
195
- const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({
195
+ // the endpoint 302s to the log blob; Octokit follows it and returns the
196
+ // body — a string for a text/* content type, an ArrayBuffer otherwise.
197
+ const { data } = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({
196
198
  owner: ctx.repo.owner,
197
199
  repo: ctx.repo.name,
198
200
  job_id: job.id,
199
201
  request: { signal: AbortSignal.timeout(10_000) },
200
202
  });
201
-
202
- const logsUrl = logsResponse.url;
203
- const logsResult = await fetch(logsUrl, { signal: AbortSignal.timeout(10_000) });
204
- if (!logsResult.ok) {
205
- throw new Error(`failed to fetch logs: ${logsResult.status} ${logsResult.statusText}`);
206
- }
207
- const logsText = await logsResult.text();
203
+ const logsText =
204
+ typeof data === "string" ? data : Buffer.from(data as ArrayBuffer).toString("utf8");
208
205
 
209
206
  // write full log to disk
210
207
  const logPath = join(logsDir, `job-${job.id}.log`);
@@ -18,6 +18,7 @@ import { getTempDir } from "#app/utils/env";
18
18
  import { computeIncrementalDiff } from "#app/utils/github/rangeDiff";
19
19
  import { createChangeImpactArtifact } from "#app/utils/prompt/changeImpact";
20
20
  import { executeLifecycleHook } from "#app/utils/run/lifecycle";
21
+ import { parseDiffLines } from "#app/utils/unifiedDiff";
21
22
 
22
23
  /**
23
24
  * checkout_pr genuinely spans two capabilities: it reads the PR + its files over
@@ -78,46 +79,19 @@ export function formatFilesWithLineNumbers(files: PullFile[]): FormatFilesResult
78
79
  continue;
79
80
  }
80
81
 
81
- // parse and format the patch with line numbers
82
- const lines = file.patch.split("\n");
83
- let oldLine = 0;
84
- let newLine = 0;
85
-
86
- for (const line of lines) {
87
- // hunk header: @@ -OLD,COUNT +NEW,COUNT @@ optional context
88
- const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
89
- if (hunkMatch) {
90
- oldLine = parseInt(hunkMatch[1]!, 10);
91
- newLine = parseInt(hunkMatch[2]!, 10);
92
- output.push(line); // pass through unchanged
93
- currentLine++;
94
- continue;
95
- }
96
-
97
- // code lines within hunks
98
- const changeType = line[0] || " ";
99
- const code = line.slice(1);
100
-
101
- if (changeType === "-") {
82
+ // format the patch with line numbers
83
+ for (const line of parseDiffLines(file.patch)) {
84
+ if (line.kind === "remove") {
102
85
  // removed line: show old line number, no new line number
103
- output.push(`| ${padNum(oldLine)} | | - | ${code}`);
104
- oldLine++;
105
- } else if (changeType === "+") {
86
+ output.push(`| ${padNum(line.oldLine)} | | - | ${line.text}`);
87
+ } else if (line.kind === "add") {
106
88
  // added line: no old line number, show new line number
107
- output.push(`| | ${padNum(newLine)} | + | ${code}`);
108
- newLine++;
109
- } else if (changeType === " " || changeType === "\\") {
110
- // context line or ""
111
- if (changeType === "\\") {
112
- output.push(line); // pass through as-is
113
- } else {
114
- output.push(`| ${padNum(oldLine)} | ${padNum(newLine)} | | ${code}`);
115
- oldLine++;
116
- newLine++;
117
- }
89
+ output.push(`| | ${padNum(line.newLine)} | + | ${line.text}`);
90
+ } else if (line.kind === "context") {
91
+ output.push(`| ${padNum(line.oldLine)} | ${padNum(line.newLine)} | | ${line.text}`);
118
92
  } else {
119
- // unknown line type, pass through
120
- output.push(line);
93
+ // hunk header, "" or unknown: pass through unchanged
94
+ output.push(line.raw);
121
95
  }
122
96
  currentLine++;
123
97
  }
@@ -481,7 +455,7 @@ export async function checkoutPrBranch(
481
455
  // dispatched on. if it's been closed/merged or the head SHA moved,
482
456
  // no amount of retrying will populate the expected ref — surface a
483
457
  // clean abort error instead of burning the full retry budget.
484
- const msg = e instanceof Error ? e.message : String(e);
458
+ const msg = errorMessage(e);
485
459
  if (PULL_REF_MISSING_PATTERN.test(msg)) {
486
460
  await abortIfPullRequestMoved({ octokit, owner, repo: name, pr });
487
461
  }
@@ -491,8 +465,7 @@ export async function checkoutPrBranch(
491
465
  {
492
466
  delaysMs: PULL_REF_RETRY_DELAYS_MS,
493
467
  label: `pull/${pr.number}/head fetch`,
494
- shouldRetry: (e) =>
495
- PULL_REF_MISSING_PATTERN.test(e instanceof Error ? e.message : String(e)),
468
+ shouldRetry: (e) => PULL_REF_MISSING_PATTERN.test(errorMessage(e)),
496
469
  },
497
470
  );
498
471
 
@@ -960,6 +960,11 @@ export function mapConcernToControls(concern: {
960
960
  category?: string;
961
961
  }): { themes: string[]; controls: ControlRef[] } {
962
962
  assertCrosswalkIntegrity();
963
+ // A style finding is about the form of the code, and no control framework has
964
+ // anything to say about it. Its evidence can still quote the code — a
965
+ // repeated literal that happens to be `0.0.0.0/0` — so it must not reach the
966
+ // signal match at all.
967
+ if (concern.category === "style") return { themes: [], controls: [] };
963
968
  const haystack = `${concern.rule_id} ${concern.evidence}`.toLowerCase();
964
969
  const locked = THEME_LOCKED.get(bareRuleId(concern.rule_id));
965
970
  const themes: string[] = [];
@@ -294,7 +294,7 @@ function severityMarker(severity: Severity): "🚨" | "⚠️" | "ℹ️" {
294
294
  const LEADING_MARKER = /^(\s*)(🚨|⚠️|⚠|ℹ️|ℹ)️?\s*/u;
295
295
 
296
296
  /** The severity marker a comment body opens with, if any. */
297
- function leadingMarker(body: string): string | null {
297
+ export function leadingMarker(body: string): string | null {
298
298
  return LEADING_MARKER.exec(body)?.[2] ?? null;
299
299
  }
300
300
 
package/src/mcp/git.ts CHANGED
@@ -19,6 +19,8 @@ import {
19
19
  assertThirdPartyAdoptionOptIn,
20
20
  enforceProtectedPaths,
21
21
  enforceRemediationPaths,
22
+ isBranchPerChangeMode,
23
+ isGuardedModeName,
22
24
  withChangedFilesMemo,
23
25
  } from "#app/mcp/guardrails";
24
26
  import { execute, tool } from "#app/mcp/shared";
@@ -302,7 +304,7 @@ async function pushWithRetry(
302
304
  return;
303
305
  } catch (err) {
304
306
  lastErr = err;
305
- const msg = err instanceof Error ? err.message : String(err);
307
+ const msg = errorMessage(err);
306
308
  const kind = classifyPushError(msg);
307
309
 
308
310
  // last attempt, or a non-retryable error (concurrent-push / unknown /
@@ -411,6 +413,17 @@ export function PushBranchTool(ctx: GitToolContext) {
411
413
  // can't slip past the default-branch guard below.
412
414
  rejectSpecialRef(branch, "branch");
413
415
 
416
+ // every guardrail below judges HEAD, so a write mode pushes the branch it
417
+ // checked: naming another one would push commits nothing here inspected.
418
+ if (branchName && isGuardedModeName(ctx.toolState.selectedMode)) {
419
+ const current = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
420
+ if (current !== branchName) {
421
+ throw new Error(
422
+ `push blocked: '${branchName}' is not the checked-out branch ('${current}'). In this mode push_branch pushes the current branch, because that is what the guardrails check — check out '${branchName}' first, or omit branchName.`,
423
+ );
424
+ }
425
+ }
426
+
414
427
  // reject push if working tree is dirty — forces agent to commit or discard before pushing
415
428
  const status = $("git", ["status", "--porcelain"], { log: false });
416
429
  if (status) {
@@ -526,6 +539,19 @@ export function PushBranchTool(ctx: GitToolContext) {
526
539
  );
527
540
  }
528
541
 
542
+ // git never rejects a force push, so the another-change check after the
543
+ // push never sees one: refuse up front to overwrite a branch this run
544
+ // did not push.
545
+ if (
546
+ force &&
547
+ isBranchPerChangeMode(ctx.toolState.selectedMode) &&
548
+ !ctx.toolState.pushedHeads?.has(pushDest.remoteBranch)
549
+ ) {
550
+ throw new Error(
551
+ `push blocked: force-pushing '${pushDest.remoteBranch}' would overwrite a branch this run did not push — if it exists, it belongs to another change. Push this change to a NEW branch name, without force.`,
552
+ );
553
+ }
554
+
529
555
  // use refspec when local and remote branch names differ
530
556
  const refspec =
531
557
  branch === pushDest.remoteBranch ? branch : `${branch}:${pushDest.remoteBranch}`;
@@ -580,8 +606,22 @@ export function PushBranchTool(ctx: GitToolContext) {
580
606
  ctx.refreshGitToken,
581
607
  );
582
608
  } catch (err) {
583
- const msg = err instanceof Error ? err.message : String(err);
609
+ const msg = errorMessage(err);
584
610
  if (classifyPushError(msg) === "concurrent-push") {
611
+ // In a mode that names a fresh branch per change, a remote branch this
612
+ // run never pushed is another change's branch, not a concurrent push
613
+ // of this one: integrating it would fold two changes into one PR, and
614
+ // adopting it would propose work this run never checked. Remediate
615
+ // names its branch after the concern group, and a re-run updates it.
616
+ if (
617
+ isBranchPerChangeMode(ctx.toolState.selectedMode) &&
618
+ !ctx.toolState.pushedHeads?.has(pushDest.remoteBranch)
619
+ ) {
620
+ throw new Error(
621
+ `push rejected: a branch named '${pushDest.remoteBranch}' already exists on the remote, and this run did not push it — it belongs to another change (an earlier run's, or a person's).\n\n` +
622
+ `do not fetch, merge, rebase onto or check out that branch. create a NEW branch name for this change from your current commit (git({ command: "checkout", args: ["-b", "${pushDest.remoteBranch}-2"] })) and push_branch again.`,
623
+ );
624
+ }
585
625
  // git rebase is blocked through the MCP tool when shell is disabled
586
626
  // (rebase --exec can execute arbitrary code). merge always works and
587
627
  // integrates remote changes cleanly, so suggest it as the default.
@@ -602,6 +642,8 @@ export function PushBranchTool(ctx: GitToolContext) {
602
642
  }
603
643
 
604
644
  const pushedSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
645
+ ctx.toolState.pushedHeads ??= new Map();
646
+ ctx.toolState.pushedHeads.set(pushDest.remoteBranch, pushedSha);
605
647
  log.info(
606
648
  `» pushed branch ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch} (sha ${pushedSha})`,
607
649
  );