infraweaver 0.3.12 → 0.3.13

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 (52) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/opencode.d.ts +6 -0
  3. package/dist/agents/quotaTokens.d.ts +6 -0
  4. package/dist/cli.mjs +554 -199
  5. package/dist/index.js +531 -176
  6. package/dist/internal.js +4 -4
  7. package/dist/mcp/git.d.ts +6 -4
  8. package/dist/mcp/localContext.d.ts +7 -0
  9. package/dist/mcp/reviewCommentLimit.d.ts +55 -5
  10. package/dist/mcp/reviewMarkers.d.ts +5 -0
  11. package/dist/mcp/staleFix.d.ts +36 -1
  12. package/dist/mcp/terraform/concernResult.d.ts +9 -0
  13. package/dist/mcp/terraform/deltaSummary.d.ts +3 -0
  14. package/dist/mcp/terraform/hardcodedSecrets.d.ts +24 -10
  15. package/dist/mcp/terraform/refactorScope.d.ts +14 -2
  16. package/dist/mcp/terraform/ruleContext.d.ts +3 -0
  17. package/dist/mcp/terraform/tree.d.ts +5 -0
  18. package/dist/mcp/terraform/types.d.ts +2 -1
  19. package/dist/mcp/terraform/verifySites.d.ts +11 -3
  20. package/dist/vendor/@infraweaver-io/models/tokenQuota.d.ts +7 -6
  21. package/package.json +2 -2
  22. package/src/agents/opencode.ts +8 -1
  23. package/src/agents/quotaTokens.ts +7 -1
  24. package/src/mcp/git.ts +64 -12
  25. package/src/mcp/localContext.ts +6 -1
  26. package/src/mcp/moduleExtraction.ts +5 -4
  27. package/src/mcp/review.ts +52 -3
  28. package/src/mcp/reviewCommentLimit.ts +169 -26
  29. package/src/mcp/reviewMarkers.ts +11 -0
  30. package/src/mcp/reviewProvenance.ts +1 -1
  31. package/src/mcp/staleFix.ts +73 -2
  32. package/src/mcp/terraform/concernResult.ts +17 -10
  33. package/src/mcp/terraform/deltaSummary.ts +4 -0
  34. package/src/mcp/terraform/hardcodedSecrets.ts +148 -27
  35. package/src/mcp/terraform/nativeRuleScanners.ts +2 -1
  36. package/src/mcp/terraform/nativeScan.ts +4 -0
  37. package/src/mcp/terraform/refactor/equivalence.ts +42 -9
  38. package/src/mcp/terraform/refactorScope.ts +90 -21
  39. package/src/mcp/terraform/ruleContext.ts +4 -0
  40. package/src/mcp/terraform/scanDelta.ts +67 -26
  41. package/src/mcp/terraform/tools/detectPlanDrift.ts +2 -0
  42. package/src/mcp/terraform/tools/ingestExternalFindings.ts +2 -0
  43. package/src/mcp/terraform/tools/normalizationCandidates.ts +5 -5
  44. package/src/mcp/terraform/tools/readFindings.ts +2 -0
  45. package/src/mcp/terraform/tools/scan.ts +3 -0
  46. package/src/mcp/terraform/tree.ts +8 -0
  47. package/src/mcp/terraform/types.ts +2 -1
  48. package/src/mcp/terraform/verifySites.ts +23 -5
  49. package/src/modes/refactor.ts +1 -1
  50. package/src/modes/remediate-and-refactor.ts +1 -1
  51. package/src/modes/remediate.ts +1 -1
  52. package/src/phases/runAgentWithWatchdogs.ts +3 -2
@@ -275,38 +275,79 @@ export function computeScanDelta(input: ScanDeltaInput): ScanDelta {
275
275
  }
276
276
 
277
277
  /**
278
- * Pair an introduced finding on a NEW resource with a resolved one that broke
279
- * the same rule and whose resource no longer exists where it was: renamed within
280
- * its module, or moved under the same name to another directory (a resource
281
- * extracted into a module without a `moved` block). Each resolved finding pairs
282
- * at most once.
278
+ * Pair a NEW resource with a vanished one — a resource whose findings resolved
279
+ * because it no longer exists where it was: renamed within its module, or moved
280
+ * under the same name to another directory (a resource extracted into a module
281
+ * without a `moved` block).
282
+ *
283
+ * Resources pair one-to-one, as `moved` blocks do: Terraform rejects two blocks
284
+ * with the same `from` or the same `to`, so a pairing made per finding — one new
285
+ * bucket matched to two old ones because each shared a different rule — would
286
+ * advise blocks that cannot be written. Each candidate pair scores the findings
287
+ * the two resources share (rule by rule, instance by instance), the best pairs
288
+ * are taken first, and ties fall to address order so the result is stable. Only
289
+ * the shared findings of a pair are labelled; a rule the vanished resource never
290
+ * broke is new on the renamed one too.
283
291
  */
284
292
  function markPossibleRenames(
285
293
  introduced: DeltaFinding[],
286
294
  resolved: DeltaFinding[],
287
295
  headAddresses: (dir: string) => ReadonlySet<string>,
288
296
  ): void {
289
- const vanished = resolved.filter((r) => {
290
- if (r.address === null) return false;
291
- return !headAddresses(dirOf(r.concern.location.file)).has(r.address);
292
- });
293
- const used = new Set<DeltaFinding>();
294
- for (const finding of introduced) {
295
- if (finding.address === null) continue;
296
- const dir = dirOf(finding.concern.location.file);
297
- const match = vanished.find(
298
- (r) =>
299
- !used.has(r) &&
300
- ruleKey(r.concern) === ruleKey(finding.concern) &&
301
- // a `moved` block cannot change a resource's type
302
- typeOf(r.address as string) === typeOf(finding.address as string) &&
303
- // renamed in place, or moved elsewhere under its own name
304
- (dirOf(r.concern.location.file) === dir) !== (r.address === finding.address),
305
- );
306
- if (!match) continue;
307
- used.add(match);
308
- finding.possiblyPreexisting = true;
309
- finding.renamedFrom = { address: match.address as string, file: match.concern.location.file };
297
+ const byResource = (findings: DeltaFinding[]) => {
298
+ const out = new Map<string, { dir: string; address: string; findings: DeltaFinding[] }>();
299
+ for (const f of [...findings].sort((a, b) => byLocation(a.concern, b.concern))) {
300
+ if (f.address === null) continue;
301
+ const dir = dirOf(f.concern.location.file);
302
+ const key = `${dir}|${f.address}`;
303
+ const entry = out.get(key);
304
+ if (entry) entry.findings.push(f);
305
+ else out.set(key, { dir, address: f.address, findings: [f] });
306
+ }
307
+ return [...out.values()];
308
+ };
309
+ const vanished = byResource(resolved).filter((r) => !headAddresses(r.dir).has(r.address));
310
+ const appeared = byResource(introduced);
311
+
312
+ // the findings of `to` that match one of `from`'s, each of `from`'s used once
313
+ const shared = (from: (typeof vanished)[number], to: (typeof appeared)[number]) => {
314
+ const left = new Map<string, number>();
315
+ for (const f of from.findings)
316
+ left.set(ruleKey(f.concern), (left.get(ruleKey(f.concern)) ?? 0) + 1);
317
+ return to.findings.filter((f) => {
318
+ const n = left.get(ruleKey(f.concern)) ?? 0;
319
+ if (n === 0) return false;
320
+ left.set(ruleKey(f.concern), n - 1);
321
+ return true;
322
+ });
323
+ };
324
+ const order = (a: { dir: string; address: string }, b: { dir: string; address: string }) =>
325
+ a.dir.localeCompare(b.dir) || a.address.localeCompare(b.address);
326
+ const candidates = vanished.flatMap((from) =>
327
+ appeared
328
+ .filter(
329
+ (to) =>
330
+ // a `moved` block cannot change a resource's type
331
+ typeOf(from.address) === typeOf(to.address) &&
332
+ // renamed in place, or moved elsewhere under its own name
333
+ (from.dir === to.dir) !== (from.address === to.address),
334
+ )
335
+ .map((to) => ({ from, to, findings: shared(from, to) }))
336
+ .filter((p) => p.findings.length > 0),
337
+ );
338
+ candidates.sort(
339
+ (a, b) => b.findings.length - a.findings.length || order(a.from, b.from) || order(a.to, b.to),
340
+ );
341
+ const used = new Set<object>();
342
+ for (const { from, to, findings } of candidates) {
343
+ if (used.has(from) || used.has(to)) continue;
344
+ used.add(from);
345
+ used.add(to);
346
+ const file = from.findings[0]?.concern.location.file as string;
347
+ for (const finding of findings) {
348
+ finding.possiblyPreexisting = true;
349
+ finding.renamedFrom = { address: from.address, file };
350
+ }
310
351
  }
311
352
  }
312
353
 
@@ -6,6 +6,7 @@ import { PRODUCT_NAME } from "#app/brand";
6
6
  import type { LocalToolContext } from "#app/mcp/localContext";
7
7
  import { walkTfFiles } from "#app/mcp/modules";
8
8
  import { execute, tool, toolOk } from "#app/mcp/shared";
9
+ import { listOpenRemediationPrs } from "#app/mcp/staleFix";
9
10
  import { buildConcernResult } from "#app/mcp/terraform/concernResult";
10
11
  import { indexTfResources } from "#app/mcp/terraform/cspm";
11
12
  import { docUrlsForGroup, ruleDocUrl } from "#app/mcp/terraform/decisions";
@@ -180,6 +181,7 @@ export function DetectPlanDriftTool(ctx: LocalToolContext) {
180
181
  // drift is a narrow concern source — join any prior scan baseline so a
181
182
  // later ✗→✓ verify doesn't read the whole workspace as regressions.
182
183
  baselineMode: "merge",
184
+ openRemediationPrs: await listOpenRemediationPrs(ctx),
183
185
  });
184
186
 
185
187
  log.info(
@@ -7,6 +7,7 @@ import { PRODUCT_NAME } from "#app/brand";
7
7
  import type { LocalToolContext } from "#app/mcp/localContext";
8
8
  import { readTextCapped, walkTfFiles } from "#app/mcp/modules";
9
9
  import { execute, tool, toolOk } from "#app/mcp/shared";
10
+ import { listOpenRemediationPrs } from "#app/mcp/staleFix";
10
11
  import { buildConcernResult } from "#app/mcp/terraform/concernResult";
11
12
  import { cappedSummary, SeedCapParams, seedCapsFrom } from "#app/mcp/terraform/coverage";
12
13
  import {
@@ -220,6 +221,7 @@ export function IngestExternalFindingsTool(ctx: LocalToolContext) {
220
221
  // baseline so a later ✗→✓ verify doesn't read the workspace as regressions.
221
222
  baselineMode: "merge",
222
223
  seedCaps: seedCapsFrom(input),
224
+ openRemediationPrs: await listOpenRemediationPrs(ctx),
223
225
  });
224
226
  const cappedBlock = cappedSummary(capped);
225
227
 
@@ -2,7 +2,7 @@ import { type } from "arktype";
2
2
  import type { LocalToolContext } from "#app/mcp/localContext";
3
3
  import { execute, tool, toolOk } from "#app/mcp/shared";
4
4
  import { findNormalizationCandidates } from "#app/mcp/terraform/normalization";
5
- import { isExamplePath, libraryRepoReason, reportOnly } from "#app/mcp/terraform/refactorScope";
5
+ import { isExamplePath, libraryScope, reportOnly } from "#app/mcp/terraform/refactorScope";
6
6
  import { log } from "#app/utils/cli";
7
7
 
8
8
  // --- the tool ----------------------------------------------------------------
@@ -29,15 +29,15 @@ export function NormalizationCandidatesTool(ctx: LocalToolContext) {
29
29
  parameters: NormalizationCandidatesParams,
30
30
  execute: execute(async () => {
31
31
  const cwd = ctx.payload.cwd ?? process.cwd();
32
- const files = findNormalizationCandidates(cwd).filter(
33
- (f) => ctx.payload.refactorExamples || !isExamplePath(f.file),
34
- );
32
+ const library = libraryScope(cwd);
33
+ const files = findNormalizationCandidates(cwd)
34
+ .filter((f) => ctx.payload.refactorExamples || !isExamplePath(f.file))
35
+ .map((f) => ({ ...reportOnly(library(f.file)), ...f }));
35
36
  const total = files.reduce((n, f) => n + f.count, 0);
36
37
  log.info(
37
38
  `» terraform_normalization_candidates: ${total} site(s) across ${files.length} file(s)`,
38
39
  );
39
40
  return toolOk({
40
- ...reportOnly(libraryRepoReason(cwd)),
41
41
  file_count: files.length,
42
42
  total,
43
43
  files,
@@ -4,6 +4,7 @@ import { type } from "arktype";
4
4
  import { PRODUCT_NAME } from "#app/brand";
5
5
  import type { LocalToolContext } from "#app/mcp/localContext";
6
6
  import { execute, tool, toolOk } from "#app/mcp/shared";
7
+ import { listOpenRemediationPrs } from "#app/mcp/staleFix";
7
8
  import { buildConcernResult } from "#app/mcp/terraform/concernResult";
8
9
  import { cappedSummary, SeedCapParams, seedCapsFrom } from "#app/mcp/terraform/coverage";
9
10
  import { docUrlsForGroup, ruleDocUrl } from "#app/mcp/terraform/decisions";
@@ -102,6 +103,7 @@ export function ReadFindingsTool(ctx: LocalToolContext) {
102
103
  grouping,
103
104
  autonomyThreshold,
104
105
  seedCaps: seedCapsFrom(input),
106
+ openRemediationPrs: await listOpenRemediationPrs(ctx),
105
107
  });
106
108
  const cappedBlock = cappedSummary(capped);
107
109
 
@@ -4,6 +4,7 @@ import { type } from "arktype";
4
4
  import type { LocalToolContext } from "#app/mcp/localContext";
5
5
  import { walkTfFiles } from "#app/mcp/modules";
6
6
  import { execute, tool, toolOk } from "#app/mcp/shared";
7
+ import { listOpenRemediationPrs } from "#app/mcp/staleFix";
7
8
  import { loadBaseline, loadResolvedLedger } from "#app/mcp/terraform/baseline";
8
9
  import { buildConcernResult } from "#app/mcp/terraform/concernResult";
9
10
  import {
@@ -148,6 +149,8 @@ export async function runScan(
148
149
  autonomyThreshold,
149
150
  inScope,
150
151
  priority,
152
+ openRemediationPrs: await listOpenRemediationPrs(ctx),
153
+ linesMatchTree: true,
151
154
  },
152
155
  );
153
156
  const { active, suppressed, groups, batchPlan, bySeverity: by_severity } = result;
@@ -176,6 +176,14 @@ export function readTofuTree(cwd: string, limits?: TreeReadLimits): SourceTree {
176
176
  return readTree(cwd, ".tofu", limits);
177
177
  }
178
178
 
179
+ /**
180
+ * The `.tfvars` inputs (`.auto.tfvars` included). Not configuration, so no rule
181
+ * reads them as blocks; the hardcoded-secret rule reads their assignments.
182
+ */
183
+ export function readTfvarsTree(cwd: string, limits?: TreeReadLimits): SourceTree {
184
+ return readTree(cwd, ".tfvars", limits);
185
+ }
186
+
179
187
  /** How many oversize paths a gap names before it stops listing them. A gap is a
180
188
  * sentence in a report, not a manifest; past a handful the count carries the
181
189
  * fact and the list only buries it. */
@@ -399,7 +399,8 @@ export interface ConcernGroup {
399
399
  * the file (by-file grouping) or the rule (by-rule grouping). */
400
400
  id: string;
401
401
  /** the branch its fix is pushed to: `remediate/<id>`, plus `--<base>` when the
402
- * PR targets a branch other than the repository default. */
402
+ * PR targets a branch other than the repository default — or the head of the
403
+ * remediation PR already open for this group into the same base. */
403
404
  branch?: string;
404
405
  /** the group's primary file (by-file) or a human label like "3 files"
405
406
  * (by-rule); `files` carries the full list for by-rule groups. */
@@ -8,7 +8,8 @@
8
8
  * a fix that moves the defect onto a new resource would look resolved — so a
9
9
  * finding counts as resolved at its own site only when ALL of these hold:
10
10
  *
11
- * - it was placed on a resource when it was reported;
11
+ * - it was placed on a resource when it was reported, by a scan of the tree
12
+ * as it then stood, and no later scan placed the same id elsewhere;
12
13
  * - that resource is still declared in the file (a renamed or deleted one
13
14
  * proves nothing);
14
15
  * - the rule no longer fires on it;
@@ -48,16 +49,33 @@ export function siteOf(c: Concern, read: (file: string) => string | null): strin
48
49
  return text === null ? null : resourceAddressAt(text, c.location.line);
49
50
  }
50
51
 
51
- /** Remember where each reported concern sat, and the first sites per key. */
52
- export function recordConcernSites(session: ScanSession, concerns: Concern[], cwd: string): void {
52
+ /**
53
+ * Remember where each reported concern sat, and the first sites per key.
54
+ *
55
+ * `linesMatchTree` says the concerns were scanned off the tree in `cwd` as it
56
+ * is now. Without it (a findings file or external report, which may describe
57
+ * another commit) their lines are not trusted to name today's resources: their
58
+ * ids get no site and their keys no first sites.
59
+ */
60
+ export function recordConcernSites(
61
+ session: ScanSession,
62
+ concerns: Concern[],
63
+ cwd: string,
64
+ linesMatchTree: boolean,
65
+ ): void {
53
66
  const read = fileReader(cwd);
54
67
  session.siteById ??= new Map();
55
68
  session.firstSitesByKey ??= new Map();
56
69
  const first = session.firstSitesByKey;
57
70
  const fresh = new Map<string, Set<string | null>>();
58
71
  for (const c of concerns) {
59
- const site = siteOf(c, read);
60
- session.siteById.set(c.id, site);
72
+ const site = linesMatchTree ? siteOf(c, read) : null;
73
+ // an id is line-pinned, so after an edit a re-scan can report a different
74
+ // finding under it. It keeps the site it was first reported at; placed
75
+ // anywhere else later, it is ambiguous and gets none.
76
+ const prior = session.siteById.get(c.id);
77
+ session.siteById.set(c.id, prior === undefined || prior === site ? site : null);
78
+ if (!linesMatchTree) continue;
61
79
  const key = concernKeyOf(c);
62
80
  if (first.has(key)) continue;
63
81
  const sites = fresh.get(key) ?? new Set();
@@ -18,7 +18,7 @@ This mode standardises STRUCTURE while preserving BEHAVIOUR — the equivalence
18
18
  - \`${t("terraform_normalization_candidates")}\` → in-place idiomatic cleanups that change SYNTAX, not behaviour (redundant whole-string \`"\${expr}"\` interpolation; legacy HCL0.11 map-argument block syntax like \`vars {\` on a \`template_file\`). These relocate NOTHING — zero \`moved {}\` blocks — and are proven by the same equivalence check.
19
19
  - \`${t("terraform_consolidation_candidates")}\` → the SAME resource shape declared in several root directories (\`env/dev\` + \`env/staging\` + \`env/prod\`), with the single parameterised module they could all call already derived: which attributes are identical (module content) and which differ (per-environment inputs). A member root of a group will ALSO show up as a zero-candidate cluster in \`${t("module_extraction_candidates")}\` — prefer the consolidation, which does that work once for every environment instead of once per environment. Its \`near_groups\` are the opposite case: roots recognisably the same stack that DIFFER (one carries a resource the other does not), where folding them together is a design decision this mode has no basis for. Do NOT extract a near-group root's shared resources into a module on its own either — a module one twin calls and the other does not widens exactly the drift the near-group reports. Leave those roots alone and name the near-group and its \`differences\` in your report.
20
20
 
21
- **A result marked \`report_only\` is an observation, not work.** The repository is a module other configurations call (\`report_only_reason\` says why), and restructuring it is churn for every caller: name those candidates in your \`${t("report_progress")}\` summary and open no PR for them. \`examples/\` roots are left out of every candidate list unless the operator set \`refactor_examples\`.
21
+ **A candidate marked \`report_only\` is an observation, not work.** It sits in the code of a module other configurations call (\`report_only_reason\` says why), and restructuring that is churn for every caller: name those candidates in your \`${t("report_progress")}\` summary and open no PR for them. \`examples/\` roots are left out of every candidate list unless the operator set \`refactor_examples\`.
22
22
 
23
23
  **Before picking, check the interface.** \`${t("module_extraction_candidates")}\` reports \`unmapped_attributes\` per candidate and a top-level \`partial_interfaces\` list: arguments the raw resources SET that the target module does not. Adopting such a module DROPS them silently — and the equivalence check cannot catch it, because once the argument is gone it is absent from both sides. Two of these are unrecoverable rather than merely wrong (\`object_lock_enabled\` cannot be set after creation; \`force_destroy\` changes deletion semantics). So: **decline the candidate, or extend the module to expose the argument and say so in the PR body.** Deleting the argument to make the shapes match is never the fix. A candidate whose \`unmapped_attributes\` is \`null\` was NOT checked — the module is external and its code is not in this repo — which is not the same as clean.
24
24
 
@@ -20,7 +20,7 @@ This mode composes the **Remediate** and **Refactor** verbs into a single run. I
20
20
  - **commit the fix** (\`git add\` only the changed \`*.tf\`/\`*.tfvars\`, a \`fix(tf): …\` message). Do NOT push yet. This committed state is the refactor's equivalence BASELINE.
21
21
 
22
22
  3. **REFACTOR (phase 2) — modularise on top of the committed fix**: follow the **Refactor** mode's flow:
23
- - \`${t("module_extraction_candidates")}\` / \`${t("terraform_normalization_candidates")}\` → pick one behaviour-preserving refactor; a result marked \`report_only\` (the repository is a module others call) is named in the summary, never proposed. Respect a pinned \`refactor_source\` if one was supplied (it constrains which module source you may use; the behaviour-altering b.4 path is only available when the operator pinned \`third-party\`).
23
+ - \`${t("module_extraction_candidates")}\` / \`${t("terraform_normalization_candidates")}\` → pick one behaviour-preserving refactor; a candidate marked \`report_only\` (it sits in the code of a module others call) is named in the summary, never proposed. Respect a pinned \`refactor_source\` if one was supplied (it constrains which module source you may use; the behaviour-altering b.4 path is only available when the operator pinned \`third-party\`).
24
24
  - resolve the module source, wire the \`module\` call against its real interface (\`${t("terraform_module_interface")}\` for a local module dir, \`${t("terraform_module_lookup")}\` for a registry-sourced one), and emit a \`moved {}\` block for EVERY relocated address (\`${t("terraform_generate_moved")}\`).
25
25
  - \`terraform fmt\` + \`${t("terraform_validate")}\`, then — **with the refactor edits still UNCOMMITTED** — call \`${t("terraform_equivalence_check")}\`. In this mode it diffs the working tree against the COMMITTED fix (not the run-start commit), so it measures only the refactor. It must return \`equivalent: true\` (zero uncovered moves, resource set + arguments preserved, validate + fmt clean) before you proceed; \`${t("push_branch")}\` hard-blocks an unproven one — and, once this run has added a \`moved {}\` block, it also hard-blocks a push that never ran the check at all, so abandoning the refactor means REVERTING its edits rather than leaving them in with the fix. If it can't be proven equivalent, abandon the refactor and ship the fix alone.
26
26
  - **document the module (only when the \`docs\` input is enabled)**: after the equivalence check passes, call \`${t("terraform_module_docs")}\` for the module you created/adopted (pass the relocated addresses as \`moves\`). Do not hand-write docs — the tool builds from the interface, moved blocks, and equivalence verdict.
@@ -34,7 +34,7 @@ export function remediateMode(t: ToolRef): Mode {
34
34
 
35
35
  4. **for the chosen group**:
36
36
  - **base branch**: this run's base branch is resolved deterministically — \`${t("create_pull_request")}\` targets the \`base_branch\` input if set, else the branch the run started on, else the repository's default branch (\`main\`, or \`master\`). You do not choose it; just **omit** the \`base\` argument when opening the PR (below) and it is filled in.
37
- - **idempotency**: the remediation branch is the group's \`branch\` (\`remediate/<group-id>\`, with a \`--<base>\` suffix when the PR targets a non-default branch) — use it exactly, never a name of your own. Before doing anything, check whether that branch or an open PR for it already exists (\`${t("git")}\` / \`${t("get_pull_request")}\`). If one exists, update it rather than opening a duplicate.
37
+ - **idempotency**: the remediation branch is the group's \`branch\` (\`remediate/<group-id>\`, with a \`--<base>\` suffix when the PR targets a non-default branch, or the branch of the PR already open for this group) — use it exactly, never a name of your own. Before doing anything, check whether that branch or an open PR for it already exists (\`${t("git")}\` / \`${t("get_pull_request")}\`). If one exists, update it rather than opening a duplicate.
38
38
  - **branch**: create the group's \`branch\` from the **current HEAD** (the checkout that was just scanned) via \`${t("git")}\` (\`git({ command: "checkout", args: ["-b", <branch>] })\`). Do NOT switch to a different base first — branching from the scanned checkout keeps the PR diff to exactly your fix.
39
39
  - **honest refusal (decide BEFORE fixing)**: if the group's concerns appear in the scan's \`refusal_candidates\` (the fix needs a human decision — narrowing an IAM wildcard, a KMS key policy, a real ingress CIDR), do **not** guess a fix that could break the stack. Instead open a structured issue (\`${t("create_issue")}\`) describing the concern, why it isn't auto-fixed, and what a human should do, and skip the PR for that group. A proven fix or an honest refusal — never a guessed, unverifiable PR.
40
40
  - **propose, then let me steer (when there's no single right fix)**: distinct from honest refusal (which refuses a fix a human must *decide*), this is for a finding with **2–3 genuinely distinct, defensible fixes** that differ in trade-offs, not correctness (e.g. encrypt with an AWS-managed key **vs** a customer-managed KMS key; a narrow security-group rule **vs** a prefix list **vs** a VPC endpoint). When such a fork exists **and the triggering comment did not already select a strategy**, do **not** silently pick for the reviewer: via \`${t("create_issue_comment")}\` post one short comment listing the options as **A / B / C** — each a single line (what it does + its trade-off) — and ask the reviewer to reply \`${COMMENT_COMMAND} fix #<concern-id> with strategy <A|B|C>\`. Then **skip the PR for this group** this run and note it in your final report (it resumes when the reviewer replies). When the comment **did** select one (\`fix #<id> with strategy B\`, or a bare \`strategy B\` reply on the proposal thread), apply **exactly** that strategy — don't second-guess it. Reserve this for real forks in the road; a fix with one obvious correct answer just gets made.
@@ -105,10 +105,11 @@ export async function runAgentWithWatchdogs(params: RunAgentParams): Promise<Age
105
105
  const costQuota = reserved ? quotas.cost : createCostQuota(quotas.costLimit.full);
106
106
  log.info(
107
107
  quotas.tokenLimit.full > 0
108
- ? `» run token quota: ${quotas.tokenLimit.full.toLocaleString("en-GB")} billable tokens` +
108
+ ? `» run token quota: ${quotas.tokenLimit.full.toLocaleString("en-GB")} tokens` +
109
109
  (reserved
110
110
  ? ` (${tokenQuota.limit.toLocaleString("en-GB")} for the run, the rest reserved to deliver findings if it runs out)`
111
- : "")
111
+ : "") +
112
+ "; a token read from the prompt cache counts as a tenth"
112
113
  : `» run token quota: disabled (${RUN_TOKEN_QUOTA_INPUT}: 0)`,
113
114
  );
114
115
  log.info(