finitemonkey 0.1.0-next.14 → 0.1.0-next.15

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 (2) hide show
  1. package/dist/finitemonkey.mjs +529 -194
  2. package/package.json +2 -1
@@ -1620,8 +1620,8 @@ var shannonEntropy = (text6) => {
1620
1620
  const counts = /* @__PURE__ */ new Map();
1621
1621
  for (const char of text6) counts.set(char, (counts.get(char) ?? 0) + 1);
1622
1622
  let entropy = 0;
1623
- for (const count3 of counts.values()) {
1624
- const p = count3 / text6.length;
1623
+ for (const count4 of counts.values()) {
1624
+ const p = count4 / text6.length;
1625
1625
  entropy -= p * Math.log2(p);
1626
1626
  }
1627
1627
  return entropy;
@@ -1670,6 +1670,35 @@ var defineRule = (spec2) => ({
1670
1670
  skipTestPaths: spec2.skipTestPaths ?? false,
1671
1671
  placeholders: spec2.placeholders ?? true
1672
1672
  });
1673
+ var isWarning = (finding3) => finding3.severity === "warning";
1674
+ var blocking = (findings) => findings.filter((finding3) => !isWarning(finding3));
1675
+
1676
+ // src/secrets/rules/exposure.ts
1677
+ var ARGUMENT_CONTEXT = String.raw`(?:^|\n)[ \t-]*(?:command|entrypoint|CMD|ENTRYPOINT|RUN|ExecStart(?:Pre|Post)?|ExecReload)\b[^\n]*?`;
1678
+ var SECRET_FLAG = String.raw`(?<=[\s"'\[,])--?[A-Za-z0-9_-]*(?:password|passwd|pass|secret|token|requirepass|api-?key)`;
1679
+ var EXPOSURE_RULES = [
1680
+ defineRule({
1681
+ id: "secret-in-process-args",
1682
+ summary: "Literal password, token or key passed as a command-line argument in compose, a Dockerfile or a systemd unit (visible in ps and docker inspect)",
1683
+ kind: "practice",
1684
+ severity: "warning",
1685
+ keywords: ["command", "entrypoint", "cmd", "run", "execstart", "execreload"],
1686
+ source: String.raw`${ARGUMENT_CONTEXT}${SECRET_FLAG}(?:=["']?|["'][ \t]*,[ \t]*["']|[ \t]+["']?)(?<secret>[^\s"',\]$\x60/][^\s"',\]\x60]{3,127})`,
1687
+ flags: "i",
1688
+ minLength: 4
1689
+ }),
1690
+ defineRule({
1691
+ id: "tls-disabled",
1692
+ summary: "TLS verification turned off in code: sslmode set to disable, rejectUnauthorized or InsecureSkipVerify set to skip checks, Python verify set to False, NODE_TLS_REJECT_UNAUTHORIZED set to 0",
1693
+ kind: "practice",
1694
+ severity: "warning",
1695
+ keywords: ["sslmode", "rejectunauthorized", "verify", "insecureskipverify", "node_tls_reject_unauthorized"],
1696
+ source: String.raw`(?<secret>\bsslmode[ \t]*=[ \t]*["']?disable\b|\brejectUnauthorized["']?[ \t]*:[ \t]*false\b|\bverify[ \t]*=[ \t]*False\b|\bInsecureSkipVerify[ \t]*:[ \t]*true\b|\bNODE_TLS_REJECT_UNAUTHORIZED["']?[ \t]*(?:=|:)[ \t]*["']?0\b)`,
1697
+ minLength: 1,
1698
+ placeholders: false,
1699
+ skipTestPaths: true
1700
+ })
1701
+ ];
1673
1702
 
1674
1703
  // src/secrets/rules/providers.ts
1675
1704
  var PROVIDER_RULES = [
@@ -1893,7 +1922,7 @@ var STRUCTURAL_RULES = [
1893
1922
  ];
1894
1923
 
1895
1924
  // src/secrets/rules/catalog.ts
1896
- var RULES = [...PROVIDER_RULES, ...STRUCTURAL_RULES];
1925
+ var RULES = [...PROVIDER_RULES, ...STRUCTURAL_RULES, ...EXPOSURE_RULES];
1897
1926
  var CREDENTIAL_RULES = RULES.filter((rule) => rule.kind === "credential");
1898
1927
  var ruleById = (id) => RULES.find((rule) => rule.id === id);
1899
1928
 
@@ -3162,9 +3191,9 @@ var skipOptions = (args, wrapper) => {
3162
3191
  }
3163
3192
  return index + wrapper.positionals;
3164
3193
  };
3165
- var shift = (stage, count3, name) => ({
3194
+ var shift = (stage, count4, name) => ({
3166
3195
  ...stage,
3167
- argv: stage.argv.slice(count3),
3196
+ argv: stage.argv.slice(count4),
3168
3197
  via: [...stage.via, name]
3169
3198
  });
3170
3199
  var envCommand = (stage) => {
@@ -3948,7 +3977,93 @@ var implicitContract = (files) => {
3948
3977
  };
3949
3978
 
3950
3979
  // src/gate/prepare.ts
3980
+ import { join as join14 } from "node:path";
3981
+
3982
+ // src/git/project-config.ts
3951
3983
  import { join as join12 } from "node:path";
3984
+ var CONFIG_FILE3 = ".finitemonkey/config.yml";
3985
+ var unquote = (value) => value.trim().replace(/^(["'])(.*)\1$/s, "$2");
3986
+ var stripComment = (line2) => {
3987
+ let quote;
3988
+ for (let index = 0; index < line2.length; index += 1) {
3989
+ const char = line2.charAt(index);
3990
+ if (quote === void 0 && (char === '"' || char === "'")) quote = char;
3991
+ else if (char === quote) quote = void 0;
3992
+ else if (quote === void 0 && char === "#" && (index === 0 || /\s/.test(line2.charAt(index - 1)))) {
3993
+ return line2.slice(0, index);
3994
+ }
3995
+ }
3996
+ return line2;
3997
+ };
3998
+ var flowList = (value) => {
3999
+ const trimmed = value.trim();
4000
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return void 0;
4001
+ return trimmed.slice(1, -1).split(",").map(unquote).filter((item) => item !== "");
4002
+ };
4003
+ var keyValue = (body2) => {
4004
+ const match = /^("[^"]*"|'[^']*'|[^:]+?)\s*:(?:\s+(.*))?$/.exec(body2);
4005
+ if (match === null) return void 0;
4006
+ return { key: unquote(match[1] ?? ""), value: match[2] ?? "" };
4007
+ };
4008
+ var addItem = (frame, item) => {
4009
+ if (frame.key === void 0) return;
4010
+ const parent = frame.tree[frame.key];
4011
+ if (Array.isArray(parent)) parent.push(item);
4012
+ else frame.tree[frame.key] = [item];
4013
+ };
4014
+ var placeLine = (stack, indent, body2) => {
4015
+ while (stack.length > 1 && (stack[stack.length - 1]?.indent ?? 0) >= indent && !body2.startsWith("- ")) stack.pop();
4016
+ const top = stack[stack.length - 1];
4017
+ if (top === void 0) return;
4018
+ if (body2.startsWith("- ")) {
4019
+ addItem(top, unquote(body2.slice(2)));
4020
+ return;
4021
+ }
4022
+ const pair = keyValue(body2);
4023
+ if (pair === void 0) return;
4024
+ const parentTree = top.key === void 0 ? top.tree : ensureTree(top.tree, top.key);
4025
+ if (pair.value === "") {
4026
+ stack.push({ indent, tree: parentTree, key: pair.key });
4027
+ return;
4028
+ }
4029
+ parentTree[pair.key] = flowList(pair.value) ?? unquote(pair.value);
4030
+ };
4031
+ var ensureTree = (tree, key) => {
4032
+ const existing = tree[key];
4033
+ if (existing !== void 0 && typeof existing !== "string" && !Array.isArray(existing)) return existing;
4034
+ const fresh3 = {};
4035
+ tree[key] = fresh3;
4036
+ return fresh3;
4037
+ };
4038
+ var parseConfigYaml = (text6) => {
4039
+ const root = {};
4040
+ const stack = [{ indent: -1, tree: root, key: void 0 }];
4041
+ for (const raw of text6.split(/\r?\n/)) {
4042
+ const line2 = stripComment(raw).trimEnd();
4043
+ if (line2.trim() === "" || line2.trim() === "---") continue;
4044
+ const indent = line2.length - line2.trimStart().length;
4045
+ placeLine(stack, indent, line2.trim());
4046
+ }
4047
+ return root;
4048
+ };
4049
+ var isTree = (node) => typeof node === "object" && !Array.isArray(node);
4050
+ var configAt = (tree, ...path) => path.reduce((node, key) => isTree(node) ? node[key] : void 0, tree);
4051
+ var configList = (tree, ...path) => {
4052
+ const node = configAt(tree, ...path);
4053
+ if (typeof node === "string") return node === "" ? [] : [node];
4054
+ return Array.isArray(node) ? node.filter((item) => typeof item === "string") : [];
4055
+ };
4056
+ var configMap = (tree, ...path) => {
4057
+ const node = configAt(tree, ...path);
4058
+ if (!isTree(node)) return {};
4059
+ return Object.fromEntries(
4060
+ Object.entries(node).filter((entry2) => typeof entry2[1] === "string")
4061
+ );
4062
+ };
4063
+ var loadProjectConfig = (runtime, root) => {
4064
+ const text6 = runtime.readText(toAbsolutePath(join12(root, CONFIG_FILE3)));
4065
+ return text6.ok ? parseConfigYaml(text6.value) : {};
4066
+ };
3952
4067
 
3953
4068
  // src/core/yaml/scalar.ts
3954
4069
  var ESCAPES = {
@@ -4096,7 +4211,7 @@ var parseValue = (text6) => {
4096
4211
 
4097
4212
  // src/core/yaml/parse.ts
4098
4213
  var BOM = new RegExp(`^${String.fromCharCode(65279)}`);
4099
- var stripComment = (text6) => {
4214
+ var stripComment2 = (text6) => {
4100
4215
  for (let index = 0; index < text6.length; index += 1) {
4101
4216
  const char = text6[index];
4102
4217
  if (char === '"' || char === "'") {
@@ -4111,7 +4226,7 @@ var toLines = (source) => {
4111
4226
  const lines3 = [];
4112
4227
  const raw = source.replace(BOM, "").split(/\r?\n/);
4113
4228
  for (const [index, full] of raw.entries()) {
4114
- const text6 = stripComment(full).trimEnd();
4229
+ const text6 = stripComment2(full).trimEnd();
4115
4230
  const body2 = text6.trimStart();
4116
4231
  if (body2 === "" || body2 === "---" && lines3.length === 0) continue;
4117
4232
  const indentText = text6.slice(0, text6.length - body2.length);
@@ -4219,7 +4334,8 @@ var CHECK_KINDS = [
4219
4334
  "worktree",
4220
4335
  "merge",
4221
4336
  "prod",
4222
- "prose"
4337
+ "prose",
4338
+ "review"
4223
4339
  ];
4224
4340
  var GIT_REQUIREMENTS = ["committed", "pushed", "pr_open"];
4225
4341
  var SIZES = ["S", "M", "L"];
@@ -4425,6 +4541,9 @@ var readBaseline = (runtime, paths) => {
4425
4541
  };
4426
4542
  var writeBaseline = (runtime, paths, treeHash2, checks) => writeJsonFile(runtime, paths.cache(BASELINE_FILE), { treeHash: treeHash2, checks: { ...checks } }).ok;
4427
4543
 
4544
+ // src/gate/diff-shape.ts
4545
+ import { join as join13 } from "node:path";
4546
+
4428
4547
  // src/gate/git.ts
4429
4548
  var EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
4430
4549
  var GIT_TIMEOUT_MS = 15e3;
@@ -4524,10 +4643,20 @@ var numstat = (git, base) => {
4524
4643
  };
4525
4644
  var dirtyPaths = (git) => nulFields(git.out(["status", "--porcelain", "-z", "--", ".", EXCLUDE_FM]) ?? "").map((entry2) => entry2.slice(3));
4526
4645
  var aheadOfUpstream = (git) => {
4527
- const count3 = git.out(["rev-list", "--count", "@{u}..HEAD"])?.trim();
4528
- return count3 === void 0 ? void 0 : Number(count3);
4646
+ const count4 = git.out(["rev-list", "--count", "@{u}..HEAD"])?.trim();
4647
+ return count4 === void 0 ? void 0 : Number(count4);
4529
4648
  };
4530
4649
 
4650
+ // src/gate/diff-shape.ts
4651
+ var untrackedLines = (runtime, root, changes) => changes.filter((change) => change.status === "A").reduce((sum, change) => {
4652
+ const text6 = runtime.readText(toAbsolutePath(join13(root, change.path)));
4653
+ return sum + (text6.ok ? text6.value.split("\n").length : 0);
4654
+ }, 0);
4655
+ var diffShape = (runtime, git, base, changes) => ({
4656
+ files: changes.map((change) => change.path),
4657
+ lines: numstat(git, base).lines + untrackedLines(runtime, git.cwd, changes)
4658
+ });
4659
+
4531
4660
  // src/gate/pr-parse.ts
4532
4661
  var FAILED = /* @__PURE__ */ new Set(["FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
4533
4662
  var PENDING = /* @__PURE__ */ new Set(["IN_PROGRESS", "QUEUED", "PENDING", "WAITING", "REQUESTED", "EXPECTED"]);
@@ -4618,13 +4747,118 @@ var conflictFiles = (git, base) => {
4618
4747
  return [];
4619
4748
  };
4620
4749
 
4750
+ // src/core/glob.ts
4751
+ var escape = (char) => /[.+^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
4752
+ var globToRegExp = (glob) => {
4753
+ const pattern = glob.replace(/^\.\//, "");
4754
+ let out = "";
4755
+ for (let index = 0; index < pattern.length; index += 1) {
4756
+ const char = pattern[index] ?? "";
4757
+ if (char === "*" && pattern[index + 1] === "*") {
4758
+ const slash = pattern[index + 2] === "/";
4759
+ out += slash ? "(?:.*/)?" : ".*";
4760
+ index += slash ? 2 : 1;
4761
+ } else if (char === "*") out += "[^/]*";
4762
+ else if (char === "?") out += "[^/]";
4763
+ else out += escape(char);
4764
+ }
4765
+ return new RegExp(`^${out}${pattern.endsWith("/") ? ".*" : ""}$`);
4766
+ };
4767
+ var matchesAny = (path, globs) => {
4768
+ const normalized = toPosixPath(path);
4769
+ return globs.some((glob) => globToRegExp(glob).test(normalized));
4770
+ };
4771
+
4772
+ // src/gate/size.ts
4773
+ var DEFAULT_SENSITIVE = [
4774
+ "**/*payment*",
4775
+ "**/*payment*/**",
4776
+ "**/*billing*",
4777
+ "**/*billing*/**",
4778
+ "**/*checkout*/**",
4779
+ "**/auth/**",
4780
+ "**/*auth.*",
4781
+ "**/oauth*",
4782
+ "**/migrations/**",
4783
+ "**/migration/**",
4784
+ "**/*.sql",
4785
+ "**/openapi*.*",
4786
+ "**/swagger*.*",
4787
+ "**/*.proto",
4788
+ "**/*.graphql",
4789
+ "**/*stripe*",
4790
+ "**/*openai*",
4791
+ "**/*anthropic*"
4792
+ ];
4793
+ var DEFAULT_SIZE_CONFIG = {
4794
+ m: { files: 5, lines: 150 },
4795
+ l: { files: 20, lines: 600 },
4796
+ sensitive: DEFAULT_SENSITIVE
4797
+ };
4798
+ var count2 = (tree, fallback, ...path) => {
4799
+ const value = configAt(tree, ...path);
4800
+ const parsed = typeof value === "string" ? Number(value) : Number.NaN;
4801
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
4802
+ };
4803
+ var threshold = (tree, key, fallback) => ({
4804
+ files: count2(tree, fallback.files, "size", key, "files"),
4805
+ lines: count2(tree, fallback.lines, "size", key, "lines")
4806
+ });
4807
+ var sizeConfig = (tree) => {
4808
+ const declared = configAt(tree, "size", "sensitive");
4809
+ return {
4810
+ m: threshold(tree, "m", DEFAULT_SIZE_CONFIG.m),
4811
+ l: threshold(tree, "l", DEFAULT_SIZE_CONFIG.l),
4812
+ sensitive: declared === void 0 ? DEFAULT_SENSITIVE : configList(tree, "size", "sensitive")
4813
+ };
4814
+ };
4815
+ var reaches = (shape, limit) => shape.files.length >= limit.files || shape.lines >= limit.lines;
4816
+ var rank = (size) => SIZES.indexOf(size);
4817
+ var larger = (a, b) => rank(a) >= rank(b) ? a : b;
4818
+ var computeSize = (shape, config) => {
4819
+ const sensitive = shape.files.filter((file) => matchesAny(file, config.sensitive));
4820
+ const byVolume = reaches(shape, config.l) ? "L" : reaches(shape, config.m) ? "M" : "S";
4821
+ const computed = sensitive.length > 0 ? larger(byVolume, "M") : byVolume;
4822
+ return { computed, files: shape.files.length, lines: shape.lines, sensitive };
4823
+ };
4824
+ var sizeVerdict = (declared, shape, config) => {
4825
+ const result = computeSize(shape, config);
4826
+ return { ...result, declared, effective: larger(declared, result.computed) };
4827
+ };
4828
+ var upgraded = (verdict) => rank(verdict.effective) > rank(verdict.declared);
4829
+
4830
+ // src/gate/size-contract.ts
4831
+ var REVIEW_CHECK = "size-review";
4832
+ var INVENTORY_CHECK = "size-inventory";
4833
+ var DECISION_CHECK = "size-decision";
4834
+ var INVENTORY = "inventory of the files, behaviours and callers the change touches";
4835
+ var DECISION = "a recorded decision on the approach for a change this large";
4836
+ var reviewChecks = (verdict) => verdict.effective === "S" ? [] : [{ id: REVIEW_CHECK, kind: "review" }];
4837
+ var largeChecks = (verdict) => verdict.effective === "L" && verdict.declared !== "S" ? [
4838
+ { id: INVENTORY_CHECK, kind: "manual", description: INVENTORY },
4839
+ { id: DECISION_CHECK, kind: "manual", description: DECISION }
4840
+ ] : [];
4841
+ var sizeRequirements = (verdict) => [
4842
+ ...reviewChecks(verdict),
4843
+ ...largeChecks(verdict)
4844
+ ];
4845
+ var applySize = (contract, verdict) => {
4846
+ if (contract.implicit) return contract;
4847
+ const declaredIds = new Set(contract.acceptance.map((check2) => check2.id));
4848
+ const declaredKinds = new Set(contract.acceptance.map((check2) => check2.kind));
4849
+ const extra = sizeRequirements(verdict).filter(
4850
+ (check2) => !declaredIds.has(check2.id) && !(check2.kind === "review" && declaredKinds.has("review"))
4851
+ );
4852
+ return extra.length === 0 && !upgraded(verdict) ? contract : { ...contract, size: verdict.effective, acceptance: [...contract.acceptance, ...extra] };
4853
+ };
4854
+
4621
4855
  // src/gate/prepare.ts
4622
4856
  var diskFiles = (runtime, root) => ({
4623
4857
  read: (relative9) => {
4624
- const text6 = runtime.readText(toAbsolutePath(join12(root, relative9)));
4858
+ const text6 = runtime.readText(toAbsolutePath(join14(root, relative9)));
4625
4859
  return text6.ok ? text6.value : void 0;
4626
4860
  },
4627
- exists: (relative9) => runtime.stat(toAbsolutePath(join12(root, relative9))) !== void 0
4861
+ exists: (relative9) => runtime.stat(toAbsolutePath(join14(root, relative9))) !== void 0
4628
4862
  });
4629
4863
  var baseFiles = (git, base, disk) => ({
4630
4864
  read: (relative9) => base === EMPTY_TREE ? disk.read(relative9) : fileAt(git, base, relative9),
@@ -4656,6 +4890,7 @@ var skipReason = (runtime, paths, loaded, changes, tree, sessionId) => {
4656
4890
  const session = readSession(runtime, paths);
4657
4891
  return session !== void 0 && session.sessionId === sessionId && session.startTree === tree ? "no_session_edits" : void 0;
4658
4892
  };
4893
+ var measureSize = (runtime, git, base, changes, contract) => sizeVerdict(contract.size, diffShape(runtime, git, base, changes), sizeConfig(loadProjectConfig(runtime, git.cwd)));
4659
4894
  var prepareGate = (options) => {
4660
4895
  const probe = gitAt(options.runtime, options.cwd);
4661
4896
  const root = gitRoot(probe);
@@ -4675,11 +4910,13 @@ var prepareGate = (options) => {
4675
4910
  const skip = options.noSkip === true ? void 0 : skipReason(options.runtime, paths, loaded.value, changes, tree, options.sessionId);
4676
4911
  if (skip !== void 0) return { kind: "skipped", reason: skip };
4677
4912
  const pr = memo(() => prStatus(options.runtime, git, paths));
4913
+ const size = measureSize(options.runtime, git, base, changes, loaded.value.contract);
4678
4914
  const ctx = {
4679
4915
  runtime: options.runtime,
4680
4916
  git,
4681
4917
  paths,
4682
- contract: loaded.value.contract,
4918
+ contract: applySize(loaded.value.contract, size),
4919
+ size,
4683
4920
  trusted: loaded.value.trusted,
4684
4921
  treeHash: tree,
4685
4922
  base,
@@ -5458,7 +5695,18 @@ var en_default = {
5458
5695
  "history.pattern.no_verify": "{count, plural, one {# git command} other {# git commands}} with --no-verify",
5459
5696
  "history.pattern.force_push": "{count, plural, one {# force push} other {# force pushes}}",
5460
5697
  "history.pattern.test_skip": "{count, plural, one {# test edit} other {# test edits}} that added .skip, .only or a skip marker",
5461
- "history.estimate": "These are estimates from pattern matching, not evidence; nothing was saved and nothing left this machine."
5698
+ "history.estimate": "These are estimates from pattern matching, not evidence; nothing was saved and nothing left this machine.",
5699
+ "gate.require.review": "a reviewer subagent read the change at its current state",
5700
+ "gate.size.declared": "size {effective}: {files, plural, one {# file} other {# files}}, {lines, plural, one {# line} other {# lines}}",
5701
+ "gate.size.grew": "declared {declared}, the diff makes it {effective}: {files, plural, one {# file} other {# files}}, {lines, plural, one {# line} other {# lines}}",
5702
+ "gate.size.sensitive": "sensitive path touched: {file}",
5703
+ "gate.review.missing": "no review of the current state: run a reviewer subagent on the diff, or the person writes `fm: approve {check} <reason>` in the chat",
5704
+ "gate.review.done": "reviewed by {agent} at {at}",
5705
+ "secrets.warn.write": "Finite Monkey warning (the write went through): {found}. A secret passed as a command-line argument shows in ps and docker inspect; disabled TLS verification lets anyone on the path read the traffic. Move the value to an environment variable or a secret file, and keep TLS checks on outside tests.",
5706
+ "gate.secrets.warning": "warning: {rule} at {file}:{line}",
5707
+ "doctor.secrets.remote_credential": "git remote {remotes} has a credential in its URL, stored in plain text in .git/config",
5708
+ "doctor.secrets.fix_remote_credential": "run `git remote set-url {remote} <url without user:token@>`, use a credential helper or SSH, and rotate the token",
5709
+ "secrets.session.remote_credential": "git remote {remotes} has a credential in its URL (plain text in .git/config). Ask the person to run `git remote set-url {remote} <url without user:token@>` and rotate the token; do not print the URL."
5462
5710
  };
5463
5711
 
5464
5712
  // src/i18n/es.json
@@ -5971,7 +6219,18 @@ var es_default = {
5971
6219
  "history.pattern.no_verify": "{count, plural, one {# comando git} other {# comandos git}} con --no-verify",
5972
6220
  "history.pattern.force_push": "{count, plural, one {# push forzado} other {# push forzados}}",
5973
6221
  "history.pattern.test_skip": "{count, plural, one {# edición} other {# ediciones}} de pruebas que añadieron .skip, .only o una marca para saltar",
5974
- "history.estimate": "Son estimaciones por patrones de texto, no evidencia; no se guardó nada y nada salió de esta máquina."
6222
+ "history.estimate": "Son estimaciones por patrones de texto, no evidencia; no se guardó nada y nada salió de esta máquina.",
6223
+ "gate.require.review": "un subagente revisor leyó el cambio en su estado actual",
6224
+ "gate.size.declared": "tamaño {effective}: {files, plural, one {# archivo} other {# archivos}}, {lines, plural, one {# línea} other {# líneas}}",
6225
+ "gate.size.grew": "declarada {declared}, el diff la vuelve {effective}: {files, plural, one {# archivo} other {# archivos}}, {lines, plural, one {# línea} other {# líneas}}",
6226
+ "gate.size.sensitive": "ruta sensible tocada: {file}",
6227
+ "gate.review.missing": "ninguna revisión del estado actual: ejecuta un subagente revisor sobre el diff, o la persona escribe `fm: approve {check} <motivo>` en el chat",
6228
+ "gate.review.done": "revisado por {agent} en {at}",
6229
+ "secrets.warn.write": "Aviso de Finite Monkey (la escritura se hizo): {found}. Un secreto pasado como argumento de línea de comandos aparece en ps y en docker inspect; la verificación TLS desactivada deja que cualquiera en el camino lea el tráfico. Lleva el valor a una variable de entorno o a un archivo de secreto y mantén el TLS verificado fuera de las pruebas.",
6230
+ "gate.secrets.warning": "aviso: {rule} en {file}:{line}",
6231
+ "doctor.secrets.remote_credential": "el remoto git {remotes} tiene una credencial en la URL, guardada en texto plano en .git/config",
6232
+ "doctor.secrets.fix_remote_credential": "ejecuta `git remote set-url {remote} <url sin usuario:token@>`, usa un credential helper o SSH y rota el token",
6233
+ "secrets.session.remote_credential": "el remoto git {remotes} tiene una credencial en la URL (texto plano en .git/config). Pide a la persona que ejecute `git remote set-url {remote} <url sin usuario:token@>` y rote el token; no imprimas la URL."
5975
6234
  };
5976
6235
 
5977
6236
  // src/i18n/pt-BR.json
@@ -6484,7 +6743,18 @@ var pt_BR_default = {
6484
6743
  "history.pattern.no_verify": "{count, plural, one {# comando git} other {# comandos git}} com --no-verify",
6485
6744
  "history.pattern.force_push": "{count, plural, one {# push forçado} other {# pushes forçados}}",
6486
6745
  "history.pattern.test_skip": "{count, plural, one {# edição} other {# edições}} de teste que acrescentaram .skip, .only ou marcador de pular",
6487
- "history.estimate": "São estimativas por padrão de texto, não evidência; nada foi salvo e nada saiu desta máquina."
6746
+ "history.estimate": "São estimativas por padrão de texto, não evidência; nada foi salvo e nada saiu desta máquina.",
6747
+ "gate.require.review": "um subagente revisor leu a mudança no estado atual",
6748
+ "gate.size.declared": "tamanho {effective}: {files, plural, one {# arquivo} other {# arquivos}}, {lines, plural, one {# linha} other {# linhas}}",
6749
+ "gate.size.grew": "declarada {declared}, o diff a torna {effective}: {files, plural, one {# arquivo} other {# arquivos}}, {lines, plural, one {# linha} other {# linhas}}",
6750
+ "gate.size.sensitive": "caminho sensível alterado: {file}",
6751
+ "gate.review.missing": "nenhuma revisão do estado atual: rode um subagente revisor no diff, ou a pessoa escreve `fm: approve {check} <motivo>` no chat",
6752
+ "gate.review.done": "revisado por {agent} em {at}",
6753
+ "secrets.warn.write": "Aviso do Finite Monkey (a escrita foi feita): {found}. Segredo passado como argumento de linha de comando aparece no ps e no docker inspect; verificação TLS desligada deixa qualquer um no caminho ler o tráfego. Leve o valor para uma variável de ambiente ou um arquivo de segredo e mantenha o TLS verificado fora dos testes.",
6754
+ "gate.secrets.warning": "aviso: {rule} em {file}:{line}",
6755
+ "doctor.secrets.remote_credential": "o remoto git {remotes} tem uma credencial na URL, guardada em texto puro no .git/config",
6756
+ "doctor.secrets.fix_remote_credential": "rode `git remote set-url {remote} <url sem usuario:token@>`, use um credential helper ou SSH e rotacione o token",
6757
+ "secrets.session.remote_credential": "o remoto git {remotes} tem uma credencial na URL (texto puro no .git/config). Peça para a pessoa rodar `git remote set-url {remote} <url sem usuario:token@>` e rotacionar o token; não imprima a URL."
6488
6758
  };
6489
6759
 
6490
6760
  // src/i18n/review.json
@@ -6998,7 +7268,18 @@ var review_default = {
6998
7268
  "history.pattern.no_verify": "machine",
6999
7269
  "history.pattern.force_push": "machine",
7000
7270
  "history.pattern.test_skip": "machine",
7001
- "history.estimate": "machine"
7271
+ "history.estimate": "machine",
7272
+ "gate.require.review": "machine",
7273
+ "gate.size.declared": "machine",
7274
+ "gate.size.grew": "machine",
7275
+ "gate.size.sensitive": "machine",
7276
+ "gate.review.missing": "machine",
7277
+ "gate.review.done": "machine",
7278
+ "secrets.warn.write": "machine",
7279
+ "gate.secrets.warning": "machine",
7280
+ "doctor.secrets.remote_credential": "machine",
7281
+ "doctor.secrets.fix_remote_credential": "machine",
7282
+ "secrets.session.remote_credential": "machine"
7002
7283
  },
7003
7284
  es: {
7004
7285
  "command.init.summary": "machine",
@@ -7509,7 +7790,18 @@ var review_default = {
7509
7790
  "history.pattern.no_verify": "machine",
7510
7791
  "history.pattern.force_push": "machine",
7511
7792
  "history.pattern.test_skip": "machine",
7512
- "history.estimate": "machine"
7793
+ "history.estimate": "machine",
7794
+ "gate.require.review": "machine",
7795
+ "gate.size.declared": "machine",
7796
+ "gate.size.grew": "machine",
7797
+ "gate.size.sensitive": "machine",
7798
+ "gate.review.missing": "machine",
7799
+ "gate.review.done": "machine",
7800
+ "secrets.warn.write": "machine",
7801
+ "gate.secrets.warning": "machine",
7802
+ "doctor.secrets.remote_credential": "machine",
7803
+ "doctor.secrets.fix_remote_credential": "machine",
7804
+ "secrets.session.remote_credential": "machine"
7513
7805
  }
7514
7806
  };
7515
7807
 
@@ -7632,7 +7924,7 @@ var buildReport = (input) => {
7632
7924
  );
7633
7925
  return fromEvidence(check2.id, check2.kind, entry2, hasStale(ledger, check2.id, treeHash2));
7634
7926
  });
7635
- const count3 = (status) => items.filter((item) => item.status === status).length;
7927
+ const count4 = (status) => items.filter((item) => item.status === status).length;
7636
7928
  return {
7637
7929
  schema: GATE_SCHEMA,
7638
7930
  task: contract.id,
@@ -7643,7 +7935,7 @@ var buildReport = (input) => {
7643
7935
  source: input.source,
7644
7936
  contractEdited: input.contractEdited,
7645
7937
  verdict: verdictOf(items),
7646
- counts: { passed: count3("passed"), failed: count3("failed"), unverified: count3("unverified") },
7938
+ counts: { passed: count4("passed"), failed: count4("failed"), unverified: count4("unverified") },
7647
7939
  items,
7648
7940
  decisionsNeeded: contract.decisionsNeeded,
7649
7941
  fastPath: input.fastPath
@@ -7767,6 +8059,27 @@ var preCompact = (input) => {
7767
8059
  return { kind: "allow" };
7768
8060
  };
7769
8061
 
8062
+ // src/gate/review.ts
8063
+ var REVIEWS_FILE = "reviews.json";
8064
+ var REVIEWER_AGENT = /(^|:)[\w-]*reviewer$/i;
8065
+ var KEEP = 50;
8066
+ var toReview = (value) => {
8067
+ if (!isRecord(value)) return void 0;
8068
+ const { treeHash: treeHash2, agent, at } = value;
8069
+ return typeof treeHash2 === "string" && typeof agent === "string" && typeof at === "string" ? { treeHash: treeHash2, agent, at } : void 0;
8070
+ };
8071
+ var readReviews = (runtime, paths) => {
8072
+ const raw = readJsonFile(runtime, paths.cache(REVIEWS_FILE))?.["reviews"];
8073
+ return Array.isArray(raw) ? raw.flatMap((item) => {
8074
+ const review2 = toReview(item);
8075
+ return review2 === void 0 ? [] : [review2];
8076
+ }) : [];
8077
+ };
8078
+ var recordReview = (runtime, paths, treeHash2, agent) => {
8079
+ const reviews = [...readReviews(runtime, paths), { treeHash: treeHash2, agent, at: runtime.now() }].slice(-KEEP);
8080
+ return writeJsonFile(runtime, paths.cache(REVIEWS_FILE), { reviews: reviews.map((review2) => ({ ...review2 })) }).ok;
8081
+ };
8082
+
7770
8083
  // src/gate/test-summary.ts
7771
8084
  var ANSI2 = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
7772
8085
  var COUNTERS = [
@@ -7906,44 +8219,14 @@ var runShellCheck = (check2, run4, ctx) => {
7906
8219
  };
7907
8220
  var commandCheck = (check2, ctx) => check2.run === void 0 ? unverified("not_implemented") : runShellCheck(check2, check2.run, ctx);
7908
8221
 
7909
- // src/gate/checks/diff.ts
7910
- import { join as join13 } from "node:path";
7911
-
7912
- // src/core/glob.ts
7913
- var escape = (char) => /[.+^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
7914
- var globToRegExp = (glob) => {
7915
- const pattern = glob.replace(/^\.\//, "");
7916
- let out = "";
7917
- for (let index = 0; index < pattern.length; index += 1) {
7918
- const char = pattern[index] ?? "";
7919
- if (char === "*" && pattern[index + 1] === "*") {
7920
- const slash = pattern[index + 2] === "/";
7921
- out += slash ? "(?:.*/)?" : ".*";
7922
- index += slash ? 2 : 1;
7923
- } else if (char === "*") out += "[^/]*";
7924
- else if (char === "?") out += "[^/]";
7925
- else out += escape(char);
7926
- }
7927
- return new RegExp(`^${out}${pattern.endsWith("/") ? ".*" : ""}$`);
7928
- };
7929
- var matchesAny = (path, globs) => {
7930
- const normalized = toPosixPath(path);
7931
- return globs.some((glob) => globToRegExp(glob).test(normalized));
7932
- };
7933
-
7934
8222
  // src/gate/checks/diff.ts
7935
8223
  var MAX_LISTED = 10;
7936
- var untrackedLines = (ctx) => ctx.changes.filter((change) => change.status === "A").reduce((sum, change) => {
7937
- const text6 = ctx.runtime.readText(toAbsolutePath(join13(ctx.paths.root, change.path)));
7938
- return sum + (text6.ok ? text6.value.split("\n").length : 0);
7939
- }, 0);
7940
8224
  var diffCheck = (_check, ctx) => {
7941
8225
  const { paths, diffBudget } = ctx.contract.scope;
7942
8226
  if (paths.length === 0 && diffBudget === void 0) return passed([note("gate.diff.no_scope")]);
7943
8227
  const outside = paths.length === 0 ? [] : ctx.changes.filter((change) => !matchesAny(change.path, paths));
7944
- const stat = numstat(ctx.git, ctx.base);
7945
8228
  const files = ctx.changes.length;
7946
- const lines3 = stat.lines + untrackedLines(ctx);
8229
+ const lines3 = diffShape(ctx.runtime, ctx.git, ctx.base, ctx.changes).lines;
7947
8230
  const notes2 = [
7948
8231
  ...outside.slice(0, MAX_LISTED).map((change) => note("gate.diff.outside", { file: change.path })),
7949
8232
  ...diffBudget?.files !== void 0 && files > diffBudget.files ? [note("gate.diff.too_many_files", { count: files, max: diffBudget.files })] : [],
@@ -7993,13 +8276,13 @@ var manualCheck = (check2, ctx) => {
7993
8276
  };
7994
8277
 
7995
8278
  // src/gate/checks/placeholders.ts
7996
- import { join as join14 } from "node:path";
8279
+ import { join as join15 } from "node:path";
7997
8280
  var MAX_LISTED2 = 10;
7998
8281
  var SKIP_FILES = /\.(md|mdx|txt|rst|lock|snap|svg|png|jpg|gif|json)$|(^|\/)(CHANGELOG|LICENSE)/i;
7999
8282
  var PLACEHOLDER = /\b(TODO|FIXME|XXX)\b|not implemented|NotImplementedError|unimplemented!|todo!\(|lorem ipsum/i;
8000
8283
  var findPlaceholders = (lines3) => lines3.filter((line2) => !isTestFile(line2.path) && !SKIP_FILES.test(line2.path) && PLACEHOLDER.test(line2.text));
8001
8284
  var untrackedAdded = (ctx) => ctx.changes.filter((change) => change.status === "A" && !SKIP_FILES.test(change.path)).flatMap((change) => {
8002
- const text6 = ctx.runtime.readText(toAbsolutePath(join14(ctx.paths.root, change.path)));
8285
+ const text6 = ctx.runtime.readText(toAbsolutePath(join15(ctx.paths.root, change.path)));
8003
8286
  return text6.ok ? text6.value.split("\n").map((line2, index) => ({ path: change.path, line: index + 1, text: line2 })) : [];
8004
8287
  });
8005
8288
  var placeholdersCheck = (_check, ctx) => {
@@ -8511,10 +8794,10 @@ var hit = (rule, units, index, weight) => {
8511
8794
  return { rule, unit: index, line: unit?.line ?? 0, weight, excerpt: unit?.text ?? "" };
8512
8795
  };
8513
8796
  var isProse = (unit) => unit?.kind === "sentence";
8514
- var samePara = (units, from, count3) => {
8797
+ var samePara = (units, from, count4) => {
8515
8798
  const first = units[from];
8516
8799
  if (!isProse(first)) return false;
8517
- return units.slice(from, from + count3).every((unit) => isProse(unit) && unit.paragraph === first.paragraph);
8800
+ return units.slice(from, from + count4).every((unit) => isProse(unit) && unit.paragraph === first.paragraph);
8518
8801
  };
8519
8802
  var runsOf = (units, size, matches) => {
8520
8803
  const starts = [];
@@ -8571,13 +8854,13 @@ var emoji = (units) => units.flatMap(
8571
8854
  var dashes = (units) => {
8572
8855
  const perParagraph = /* @__PURE__ */ new Map();
8573
8856
  for (const unit of units) {
8574
- const count3 = unit.text.split("—").length - 1;
8575
- perParagraph.set(unit.paragraph, (perParagraph.get(unit.paragraph) ?? 0) + count3);
8857
+ const count4 = unit.text.split("—").length - 1;
8858
+ perParagraph.set(unit.paragraph, (perParagraph.get(unit.paragraph) ?? 0) + count4);
8576
8859
  }
8577
- return [...perParagraph].flatMap(([paragraph, count3]) => {
8578
- if (count3 === 0) return [];
8860
+ return [...perParagraph].flatMap(([paragraph, count4]) => {
8861
+ if (count4 === 0) return [];
8579
8862
  const index = units.findIndex((unit) => unit.paragraph === paragraph && unit.text.includes("—"));
8580
- const weight = count3 >= 2 ? STRUCTURE_WEIGHTS.dashSeries : STRUCTURE_WEIGHTS.dash;
8863
+ const weight = count4 >= 2 ? STRUCTURE_WEIGHTS.dashSeries : STRUCTURE_WEIGHTS.dash;
8581
8864
  return [hit("em-dash-series", units, index, weight)];
8582
8865
  });
8583
8866
  };
@@ -8605,11 +8888,11 @@ var windowEnd = (units, from, size) => {
8605
8888
  return to;
8606
8889
  };
8607
8890
  var windowScore = (hits, window) => hits.filter((item) => item.unit >= window.from && item.unit <= window.to).reduce((sum, item) => sum + item.weight, 0);
8608
- var hotWindows = (hits, units, size, threshold) => {
8891
+ var hotWindows = (hits, units, size, threshold2) => {
8609
8892
  const windows = [];
8610
8893
  for (let from = 0; from < units.length; from += 1) {
8611
8894
  const window = { from, to: windowEnd(units, from, size) };
8612
- if (windowScore(hits, window) >= threshold) windows.push(window);
8895
+ if (windowScore(hits, window) >= threshold2) windows.push(window);
8613
8896
  }
8614
8897
  return windows;
8615
8898
  };
@@ -8636,10 +8919,10 @@ var byPosition = (left, right) => left.unit - right.unit || left.line - right.li
8636
8919
  var scoreProse = (text6, options = {}) => {
8637
8920
  const { units, allowed: allowed2 } = extractProse(text6);
8638
8921
  const size = options.window ?? DENSITY_WINDOW;
8639
- const threshold = options.threshold ?? DENSITY_THRESHOLD;
8922
+ const threshold2 = options.threshold ?? DENSITY_THRESHOLD;
8640
8923
  const catalog = options.context === "record" ? [...phraseRules(), ...recordRules()] : phraseRules();
8641
8924
  const hits = [...phraseHits(units, catalog), ...structureHits(units)].sort(byPosition);
8642
- const spans = mergeSpans(hits, hotWindows(hits, units, size, threshold));
8925
+ const spans = mergeSpans(hits, hotWindows(hits, units, size, threshold2));
8643
8926
  return { hits, spans, allowed: allowed2 };
8644
8927
  };
8645
8928
  var isFlagged = (report2) => report2.spans.length > 0;
@@ -8689,22 +8972,55 @@ var MAX_LISTED3 = 10;
8689
8972
  var rulesOf = (span) => [...new Set(span.hits.map((hit3) => hit3.rule))].join(", ");
8690
8973
  var proseOutcome = (status) => {
8691
8974
  if (!status.ok) return unverified("tool_missing", [note("gate.prose.unavailable")]);
8692
- const { files, flagged, blocking } = status.value;
8693
- const key = blocking ? "gate.prose.finding" : "gate.prose.warning";
8975
+ const { files, flagged, blocking: blocking2 } = status.value;
8976
+ const key = blocking2 ? "gate.prose.finding" : "gate.prose.warning";
8694
8977
  const notes2 = flagged.flatMap((item) => item.report.spans.map((span) => ({ file: item.file, span }))).slice(0, MAX_LISTED3).map(({ file, span }) => note(key, { file, line: span.startLine, rules: rulesOf(span) }));
8695
8978
  if (notes2.length === 0) return passed([note("gate.prose.clean", { count: files.length })]);
8696
- return blocking ? failed(notes2) : passed(notes2);
8979
+ return blocking2 ? failed(notes2) : passed(notes2);
8697
8980
  };
8698
8981
  var proseCheck = (_check, ctx) => proseOutcome(proseGateStatus(ctx.runtime, ctx.paths.root));
8699
8982
 
8983
+ // src/gate/checks/review.ts
8984
+ var MAX_SENSITIVE = 5;
8985
+ var sizeNotes = (ctx) => {
8986
+ const size = ctx.size;
8987
+ if (size === void 0) return [];
8988
+ return [
8989
+ note(size.declared === size.effective ? "gate.size.declared" : "gate.size.grew", {
8990
+ declared: size.declared,
8991
+ effective: size.effective,
8992
+ files: size.files,
8993
+ lines: size.lines
8994
+ }),
8995
+ ...size.sensitive.slice(0, MAX_SENSITIVE).map((file) => note("gate.size.sensitive", { file }))
8996
+ ];
8997
+ };
8998
+ var reviewCheck = (check2, ctx) => {
8999
+ const review2 = readReviews(ctx.runtime, ctx.paths).findLast((entry2) => entry2.treeHash === ctx.treeHash);
9000
+ if (review2 !== void 0) {
9001
+ return { status: "passed", notes: [note("gate.review.done", { agent: review2.agent, at: review2.at })] };
9002
+ }
9003
+ const approval = readApprovals(ctx.runtime, ctx.paths).findLast(
9004
+ (item) => item.task === ctx.contract.id && item.check === check2.id
9005
+ );
9006
+ if (approval !== void 0) {
9007
+ return {
9008
+ status: "passed",
9009
+ notes: [note("gate.manual.approved", { at: approval.at, reason: approval.reason })],
9010
+ releasedBy: `person@${approval.at}`
9011
+ };
9012
+ }
9013
+ return failed([...sizeNotes(ctx), note("gate.review.missing", { check: check2.id })]);
9014
+ };
9015
+
8700
9016
  // src/secrets/baseline.ts
8701
- import { join as join15 } from "node:path";
9017
+ import { join as join16 } from "node:path";
8702
9018
  var BASELINE_FILE2 = ".finitemonkey/findings/baseline.json";
8703
9019
  var BASELINE_SCHEMA = "finitemonkey/secrets-baseline@1";
8704
9020
  var BASELINE_STATES = ["aceito", "falso-positivo"];
8705
9021
  var EXCEPTION_MAX_DAYS = 90;
8706
9022
  var EMPTY_BASELINE = { schema: BASELINE_SCHEMA, entries: [] };
8707
- var baselinePath = (root) => toAbsolutePath(join15(root, BASELINE_FILE2));
9023
+ var baselinePath = (root) => toAbsolutePath(join16(root, BASELINE_FILE2));
8708
9024
  var text5 = (record2, key) => {
8709
9025
  const value = record2[key];
8710
9026
  return typeof value === "string" && value !== "" ? value : void 0;
@@ -8764,14 +9080,14 @@ var withoutFixed = (baseline, fixed) => {
8764
9080
  var acceptedFingerprints = (baseline, now) => new Set(baseline.entries.filter((entry2) => entry2.expiresAt >= now).map((entry2) => entry2.fingerprint));
8765
9081
 
8766
9082
  // src/secrets/incidents.ts
8767
- import { join as join16 } from "node:path";
9083
+ import { join as join17 } from "node:path";
8768
9084
  var INCIDENTS_DIR = ".finitemonkey/incidents";
8769
9085
  var INCIDENT_STATES = ["aberto", "exposicao-local", "rotacionado", "fechado"];
8770
9086
  var INCIDENT_SOURCES = ["tool-output", "prompt", "history", "scan"];
8771
9087
  var ID = /^inc-[0-9a-f]{12}$/;
8772
9088
  var incidentId = (rule, fingerprint) => `inc-${sha256Hex(`${rule}\0${fingerprint}`).slice(0, 12)}`;
8773
- var incidentsDir = (root) => toAbsolutePath(join16(root, INCIDENTS_DIR));
8774
- var incidentFile = (root, id) => toAbsolutePath(join16(incidentsDir(root), `${id}.json`));
9089
+ var incidentsDir = (root) => toAbsolutePath(join17(root, INCIDENTS_DIR));
9090
+ var incidentFile = (root, id) => toAbsolutePath(join17(incidentsDir(root), `${id}.json`));
8775
9091
  var pick2 = (options, value) => options.find((option) => option === value);
8776
9092
  var str2 = (record2, key) => {
8777
9093
  const value = record2[key];
@@ -8826,7 +9142,7 @@ var listIncidents = (runtime, root) => {
8826
9142
  return names.value.filter((name) => name.endsWith(".json")).map((name) => readIncident(runtime, root, name.slice(0, -".json".length))).flatMap((parsed) => parsed.ok ? [parsed.value] : []).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
8827
9143
  };
8828
9144
  var saveIncident = (runtime, root, incident) => {
8829
- const ignore = runtime.writeText(toAbsolutePath(join16(incidentsDir(root), ".gitignore")), "*\n");
9145
+ const ignore = runtime.writeText(toAbsolutePath(join17(incidentsDir(root), ".gitignore")), "*\n");
8830
9146
  if (!ignore.ok) return ignore;
8831
9147
  return runtime.writeText(incidentFile(root, incident.id), `${JSON.stringify(incident, null, 2)}
8832
9148
  `);
@@ -8873,7 +9189,7 @@ var moveIncident = (incident, to, now, by) => ({
8873
9189
  var isRotated = (incident) => incident.state === "rotacionado" || incident.state === "fechado";
8874
9190
 
8875
9191
  // src/secrets/scan.ts
8876
- import { join as join17, relative as relative4 } from "node:path";
9192
+ import { join as join18, relative as relative4 } from "node:path";
8877
9193
 
8878
9194
  // src/secrets/located.ts
8879
9195
  var findingId = (ruleId, file, fingerprint) => `fm-${sha256Hex(`${ruleId}\0${file}\0${fingerprint}`).slice(0, 16)}`;
@@ -8973,7 +9289,7 @@ var listed2 = (runtime, root, target) => {
8973
9289
  const base = toAbsolutePath(target, root);
8974
9290
  const walked = runtime.walkFiles(base);
8975
9291
  if (!walked.ok) return walked;
8976
- return accept(walked.value.map((name) => toPosixPath(relative4(root, join17(base, toPosixPath(name))))));
9292
+ return accept(walked.value.map((name) => toPosixPath(relative4(root, join18(base, toPosixPath(name))))));
8977
9293
  };
8978
9294
  var scanFile = (runtime, root, file) => {
8979
9295
  const text6 = runtime.readText(toAbsolutePath(file, root));
@@ -9033,15 +9349,17 @@ var secretsCheck = (_check, ctx) => {
9033
9349
  const status = secretsGateStatus(ctx.runtime, ctx.paths.root, sinceOf(ctx));
9034
9350
  if (!status.ok) return unverified("tool_missing", [note("gate.secrets.unavailable")]);
9035
9351
  const { findings, openIncidents } = status.value;
9352
+ const warnings = findings.filter(isWarning).slice(0, MAX_LISTED4).map((finding3) => note("gate.secrets.warning", { file: finding3.file, line: finding3.line, rule: finding3.ruleId }));
9036
9353
  const notes2 = [
9037
- ...findings.slice(0, MAX_LISTED4).map((finding3) => note("gate.secrets.finding", { file: finding3.file, line: finding3.line, rule: finding3.ruleId })),
9354
+ ...blocking(findings).slice(0, MAX_LISTED4).map((finding3) => note("gate.secrets.finding", { file: finding3.file, line: finding3.line, rule: finding3.ruleId })),
9038
9355
  ...openIncidents.slice(0, MAX_LISTED4).map((incident) => note("gate.secrets.incident", { id: incident.id, state: incident.state }))
9039
9356
  ];
9040
- return notes2.length > 0 ? failed(notes2) : passed([note("gate.secrets.clean")]);
9357
+ if (notes2.length > 0) return failed([...notes2, ...warnings]);
9358
+ return passed(warnings.length > 0 ? warnings : [note("gate.secrets.clean")]);
9041
9359
  };
9042
9360
 
9043
9361
  // src/gate/checks/tests.ts
9044
- import { join as join18 } from "node:path";
9362
+ import { join as join19 } from "node:path";
9045
9363
 
9046
9364
  // src/guards/tests/tree.ts
9047
9365
  var outOfScope = (input, change) => input.testsProtected && input.scopePaths.length > 0 && change.status !== "A" && isTestFile(change.path) && !matchesAny(change.path, input.scopePaths) ? [{ kind: "out_of_scope", path: change.path }] : [];
@@ -9073,7 +9391,7 @@ var treeViolations = (ctx) => {
9073
9391
  changes: relevant,
9074
9392
  before: (path) => fileAt(ctx.git, ctx.base, path),
9075
9393
  after: (path) => {
9076
- const text6 = ctx.runtime.readText(toAbsolutePath(join18(ctx.paths.root, path)));
9394
+ const text6 = ctx.runtime.readText(toAbsolutePath(join19(ctx.paths.root, path)));
9077
9395
  return text6.ok ? text6.value : void 0;
9078
9396
  },
9079
9397
  acceptedConfigs: ctx.trust.configs,
@@ -9094,7 +9412,7 @@ var testsCheck = (check2, ctx) => {
9094
9412
  };
9095
9413
 
9096
9414
  // src/git/tree.ts
9097
- import { join as join19 } from "node:path";
9415
+ import { join as join20 } from "node:path";
9098
9416
 
9099
9417
  // src/secrets/guards/paths.ts
9100
9418
  var SAFE_ENV = /^\.env\.(?:example|sample|template|dist|defaults|schema)$/i;
@@ -9133,7 +9451,7 @@ var dropSensitive = (runtime, repo, env, options) => {
9133
9451
  return removed.ok ? accept(void 0) : removed;
9134
9452
  };
9135
9453
  var withTempIndex = (runtime, repo, options, work) => {
9136
- const path = toAbsolutePath(join19(repo.gitDir, `finitemonkey-${options.stamp}.index`));
9454
+ const path = toAbsolutePath(join20(repo.gitDir, `finitemonkey-${options.stamp}.index`));
9137
9455
  const env = { GIT_INDEX_FILE: path, GIT_OPTIONAL_LOCKS: "0" };
9138
9456
  try {
9139
9457
  return work(env);
@@ -9366,7 +9684,7 @@ var entryOf2 = (kind, xy, path) => ({
9366
9684
  staged: kind !== "untracked" && !xy.startsWith("."),
9367
9685
  unstaged: kind === "untracked" || xy.charAt(1) !== "."
9368
9686
  });
9369
- var afterFields = (record2, count3) => record2.split(" ").slice(count3).join(" ");
9687
+ var afterFields = (record2, count4) => record2.split(" ").slice(count4).join(" ");
9370
9688
  var parsePorcelainV2 = (stdout) => {
9371
9689
  const records = stdout.split("\0");
9372
9690
  const out = [];
@@ -9384,92 +9702,6 @@ var parsePorcelainV2 = (stdout) => {
9384
9702
  return out.filter((entry2) => entry2.path !== "" && !entry2.path.startsWith(STATE_PREFIX));
9385
9703
  };
9386
9704
 
9387
- // src/git/project-config.ts
9388
- import { join as join20 } from "node:path";
9389
- var CONFIG_FILE3 = ".finitemonkey/config.yml";
9390
- var unquote = (value) => value.trim().replace(/^(["'])(.*)\1$/s, "$2");
9391
- var stripComment2 = (line2) => {
9392
- let quote;
9393
- for (let index = 0; index < line2.length; index += 1) {
9394
- const char = line2.charAt(index);
9395
- if (quote === void 0 && (char === '"' || char === "'")) quote = char;
9396
- else if (char === quote) quote = void 0;
9397
- else if (quote === void 0 && char === "#" && (index === 0 || /\s/.test(line2.charAt(index - 1)))) {
9398
- return line2.slice(0, index);
9399
- }
9400
- }
9401
- return line2;
9402
- };
9403
- var flowList = (value) => {
9404
- const trimmed = value.trim();
9405
- if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return void 0;
9406
- return trimmed.slice(1, -1).split(",").map(unquote).filter((item) => item !== "");
9407
- };
9408
- var keyValue = (body2) => {
9409
- const match = /^("[^"]*"|'[^']*'|[^:]+?)\s*:(?:\s+(.*))?$/.exec(body2);
9410
- if (match === null) return void 0;
9411
- return { key: unquote(match[1] ?? ""), value: match[2] ?? "" };
9412
- };
9413
- var addItem = (frame, item) => {
9414
- if (frame.key === void 0) return;
9415
- const parent = frame.tree[frame.key];
9416
- if (Array.isArray(parent)) parent.push(item);
9417
- else frame.tree[frame.key] = [item];
9418
- };
9419
- var placeLine = (stack, indent, body2) => {
9420
- while (stack.length > 1 && (stack[stack.length - 1]?.indent ?? 0) >= indent && !body2.startsWith("- ")) stack.pop();
9421
- const top = stack[stack.length - 1];
9422
- if (top === void 0) return;
9423
- if (body2.startsWith("- ")) {
9424
- addItem(top, unquote(body2.slice(2)));
9425
- return;
9426
- }
9427
- const pair = keyValue(body2);
9428
- if (pair === void 0) return;
9429
- const parentTree = top.key === void 0 ? top.tree : ensureTree(top.tree, top.key);
9430
- if (pair.value === "") {
9431
- stack.push({ indent, tree: parentTree, key: pair.key });
9432
- return;
9433
- }
9434
- parentTree[pair.key] = flowList(pair.value) ?? unquote(pair.value);
9435
- };
9436
- var ensureTree = (tree, key) => {
9437
- const existing = tree[key];
9438
- if (existing !== void 0 && typeof existing !== "string" && !Array.isArray(existing)) return existing;
9439
- const fresh3 = {};
9440
- tree[key] = fresh3;
9441
- return fresh3;
9442
- };
9443
- var parseConfigYaml = (text6) => {
9444
- const root = {};
9445
- const stack = [{ indent: -1, tree: root, key: void 0 }];
9446
- for (const raw of text6.split(/\r?\n/)) {
9447
- const line2 = stripComment2(raw).trimEnd();
9448
- if (line2.trim() === "" || line2.trim() === "---") continue;
9449
- const indent = line2.length - line2.trimStart().length;
9450
- placeLine(stack, indent, line2.trim());
9451
- }
9452
- return root;
9453
- };
9454
- var isTree = (node) => typeof node === "object" && !Array.isArray(node);
9455
- var configAt = (tree, ...path) => path.reduce((node, key) => isTree(node) ? node[key] : void 0, tree);
9456
- var configList = (tree, ...path) => {
9457
- const node = configAt(tree, ...path);
9458
- if (typeof node === "string") return node === "" ? [] : [node];
9459
- return Array.isArray(node) ? node.filter((item) => typeof item === "string") : [];
9460
- };
9461
- var configMap = (tree, ...path) => {
9462
- const node = configAt(tree, ...path);
9463
- if (!isTree(node)) return {};
9464
- return Object.fromEntries(
9465
- Object.entries(node).filter((entry2) => typeof entry2[1] === "string")
9466
- );
9467
- };
9468
- var loadProjectConfig = (runtime, root) => {
9469
- const text6 = runtime.readText(toAbsolutePath(join20(root, CONFIG_FILE3)));
9470
- return text6.ok ? parseConfigYaml(text6.value) : {};
9471
- };
9472
-
9473
9705
  // src/git/merge/dropped.ts
9474
9706
  import { join as join21 } from "node:path";
9475
9707
 
@@ -10184,7 +10416,8 @@ var DEFAULT_CHECKS = {
10184
10416
  secrets: secretsCheck,
10185
10417
  prose: proseCheck,
10186
10418
  worktree: worktreeCheck,
10187
- merge: mergeCheck
10419
+ merge: mergeCheck,
10420
+ review: reviewCheck
10188
10421
  };
10189
10422
 
10190
10423
  // src/gate/run.ts
@@ -10364,7 +10597,18 @@ var isOwnWorktree = (input) => {
10364
10597
  const main2 = findRepoRoot(runtime, input.cwd);
10365
10598
  return front !== void 0 && main2 !== void 0 && front !== main2;
10366
10599
  };
10367
- var subagentStopGate = (input) => isOwnWorktree(input) ? stopGate(input) : { kind: "allow" };
10600
+ var noteReview = (input) => {
10601
+ const agent = textField(input.payload.raw, "agent_type") ?? "";
10602
+ if (!REVIEWER_AGENT.test(agent)) return;
10603
+ const root = findRepoRoot(input.runtime, sessionCwd(input));
10604
+ if (root === void 0) return;
10605
+ const tree = treeHash(gitAt(input.runtime, root));
10606
+ if (tree !== void 0) recordReview(input.runtime, fmPaths(root), tree, agent);
10607
+ };
10608
+ var subagentStopGate = (input) => {
10609
+ noteReview(input);
10610
+ return isOwnWorktree(input) ? stopGate(input) : { kind: "allow" };
10611
+ };
10368
10612
 
10369
10613
  // src/gate/hooks/subagent.ts
10370
10614
  import { join as join24 } from "node:path";
@@ -10380,7 +10624,8 @@ var KNOWN_KINDS = /* @__PURE__ */ new Set([
10380
10624
  "secrets",
10381
10625
  "prose",
10382
10626
  "worktree",
10383
- "merge"
10627
+ "merge",
10628
+ "review"
10384
10629
  ]);
10385
10630
  var requirementText = (t, check2) => {
10386
10631
  const key = `gate.require.${check2.kind}`;
@@ -12337,10 +12582,10 @@ var textOf2 = (input, found) => {
12337
12582
  }
12338
12583
  return accept(parts.join("\n\n"));
12339
12584
  };
12340
- var decisionFor = (input, found, blocking) => {
12585
+ var decisionFor = (input, found, blocking2) => {
12341
12586
  const text6 = textOf2(input, found);
12342
12587
  if (!text6.ok) {
12343
- return blocking ? { kind: "block", reason: input.t("prose.block.unreadable", { ...text6.error }) } : void 0;
12588
+ return blocking2 ? { kind: "block", reason: input.t("prose.block.unreadable", { ...text6.error }) } : void 0;
12344
12589
  }
12345
12590
  const report2 = scoreProse(text6.value, { context: "record" });
12346
12591
  if (report2.allowed || report2.spans.length === 0) return void 0;
@@ -12349,7 +12594,7 @@ var decisionFor = (input, found, blocking) => {
12349
12594
  spans: describeSpans(input.t, report2.spans),
12350
12595
  fixes: describeFixes(input.t, report2.spans)
12351
12596
  };
12352
- if (blocking) return { kind: "block", reason: input.t("prose.block.command", params2) };
12597
+ if (blocking2) return { kind: "block", reason: input.t("prose.block.command", params2) };
12353
12598
  const additionalContext = `finitemonkey: ${input.t("prose.warn.command", params2)}`;
12354
12599
  return { kind: "allow", output: { hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext } } };
12355
12600
  };
@@ -12357,9 +12602,9 @@ var proseCommandGuard = (input) => {
12357
12602
  if (!SHELLS3.has(toolName2(input.payload))) return ALLOW4;
12358
12603
  const found = proseInCommand(optionalString(toolInput2(input.payload), "command") ?? "");
12359
12604
  if (found.length === 0) return ALLOW4;
12360
- const blocking = projectPreset(input.runtime, projectRoot(input)) === "gabriel";
12605
+ const blocking2 = projectPreset(input.runtime, projectRoot(input)) === "gabriel";
12361
12606
  for (const item of found) {
12362
- const decision = decisionFor(input, item, blocking);
12607
+ const decision = decisionFor(input, item, blocking2);
12363
12608
  if (decision !== void 0) return decision;
12364
12609
  }
12365
12610
  return ALLOW4;
@@ -12523,11 +12768,24 @@ var relativeFile = (cwd, file) => {
12523
12768
  const rel = relative7(cwd, file).split("\\").join("/");
12524
12769
  return rel.startsWith("..") || rel === "" ? file : rel;
12525
12770
  };
12771
+ var warningDecision = (t, warnings) => {
12772
+ if (warnings.length === 0) return ALLOW5;
12773
+ const context = t("secrets.warn.write", { found: describeFindings(t, warnings) });
12774
+ return { kind: "allow", output: { hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: context } } };
12775
+ };
12526
12776
  var writeDecision = (input, tool) => {
12527
12777
  const { t } = input;
12778
+ const warnings = [];
12528
12779
  for (const written of writtenTexts(tool, toolInput2(input.payload))) {
12529
12780
  const file = relativeFile(input.cwd, written.file);
12530
- const found = scanText(written.text, { path: file });
12781
+ const all = scanText(written.text, { path: file });
12782
+ warnings.push(
12783
+ ...all.filter(isWarning).map((finding3) => ({
12784
+ ruleId: finding3.ruleId,
12785
+ where: t("secrets.where.file", { file, line: finding3.line, column: finding3.column })
12786
+ }))
12787
+ );
12788
+ const found = blocking(all);
12531
12789
  if (found.length === 0) continue;
12532
12790
  const items = found.map((finding3) => ({
12533
12791
  ruleId: finding3.ruleId,
@@ -12535,7 +12793,7 @@ var writeDecision = (input, tool) => {
12535
12793
  }));
12536
12794
  return { kind: "block", reason: t("secrets.block.write", { file, found: describeFindings(t, items) }) };
12537
12795
  }
12538
- return ALLOW5;
12796
+ return warningDecision(t, warnings);
12539
12797
  };
12540
12798
  var bashMessage = (t, finding3) => {
12541
12799
  switch (finding3.kind) {
@@ -12549,8 +12807,9 @@ var bashMessage = (t, finding3) => {
12549
12807
  };
12550
12808
  var commitDecision = (input, includeUnstaged) => {
12551
12809
  const staged = scanStaged(input.runtime, input.cwd, includeUnstaged);
12552
- if (!staged.ok || staged.value.length === 0) return ALLOW5;
12553
- const items = staged.value.map((finding3) => ({
12810
+ const found = staged.ok ? blocking(staged.value) : [];
12811
+ if (found.length === 0) return ALLOW5;
12812
+ const items = found.map((finding3) => ({
12554
12813
  ruleId: finding3.ruleId,
12555
12814
  where: input.t("secrets.where.file", { file: finding3.file, line: finding3.line, column: finding3.column })
12556
12815
  }));
@@ -12562,7 +12821,7 @@ var bashDecision = (input) => {
12562
12821
  const segments = segmentsOf(command);
12563
12822
  const [first] = bashFindings(command, segments);
12564
12823
  if (first !== void 0) return { kind: "block", reason: bashMessage(input.t, first) };
12565
- const found = scanText(command);
12824
+ const found = blocking(scanText(command));
12566
12825
  if (found.length > 0) {
12567
12826
  const items = found.map((finding3) => ({ ruleId: finding3.ruleId, where: lineWhere(input.t, finding3) }));
12568
12827
  return { kind: "block", reason: input.t("secrets.block.bash", { found: describeFindings(input.t, items) }) };
@@ -12587,6 +12846,44 @@ var secretsPreToolUse = (input) => {
12587
12846
  return ALLOW5;
12588
12847
  };
12589
12848
 
12849
+ // src/secrets/remotes.ts
12850
+ var USER_AND_SECRET = /^[a-z][a-z0-9+.-]*:\/\/[^\s/@:]+:[^\s/@]+@/i;
12851
+ var USERINFO = /^[a-z][a-z0-9+.-]*:\/\/(?<user>[^\s/@]+)@/i;
12852
+ var hasCredential = (url) => {
12853
+ if (USER_AND_SECRET.test(url)) return true;
12854
+ const user = USERINFO.exec(url)?.groups?.["user"];
12855
+ return user !== void 0 && scanText(user, { rules: CREDENTIAL_RULES }).length > 0;
12856
+ };
12857
+ var parseRemotes = (output) => {
12858
+ const remotes = /* @__PURE__ */ new Map();
12859
+ for (const line2 of output.split(/\r?\n/)) {
12860
+ const [name, url] = line2.trim().split(/\s+/);
12861
+ if (name === void 0 || url === void 0 || name === "") continue;
12862
+ remotes.set(name, [...remotes.get(name) ?? [], url]);
12863
+ }
12864
+ return remotes;
12865
+ };
12866
+ var remotesWithCredentials = (runtime, root) => {
12867
+ const listed4 = runtime.git(["remote", "-v"], root);
12868
+ if (!listed4.ok) return [];
12869
+ return [...parseRemotes(listed4.stdout)].filter(([, urls]) => urls.some(hasCredential)).map(([name]) => name).sort();
12870
+ };
12871
+
12872
+ // src/secrets/guards/session.ts
12873
+ var secretsSessionStart = (input) => {
12874
+ const remotes = remotesWithCredentials(input.runtime, input.cwd);
12875
+ if (remotes.length === 0) return { kind: "allow" };
12876
+ const params2 = { remotes: remotes.join(", "), remote: remotes[0] ?? "origin" };
12877
+ const text6 = input.t("secrets.session.remote_credential", params2);
12878
+ return {
12879
+ kind: "allow",
12880
+ output: {
12881
+ systemMessage: `finitemonkey: ${text6}`,
12882
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: text6 }
12883
+ }
12884
+ };
12885
+ };
12886
+
12590
12887
  // src/hooks/handlers.ts
12591
12888
  var allow = () => ({ kind: "allow" });
12592
12889
  var DECISION_RANK = { allow: 0, defer: 0, ask: 1, deny: 2 };
@@ -12640,7 +12937,8 @@ var mergeHandlers = (...sets) => {
12640
12937
  var SECRETS_HANDLERS = {
12641
12938
  PreToolUse: [secretsPreToolUse],
12642
12939
  UserPromptSubmit: [secretsUserPromptSubmit],
12643
- PostToolUse: [secretsPostToolUse]
12940
+ PostToolUse: [secretsPostToolUse],
12941
+ SessionStart: [secretsSessionStart]
12644
12942
  };
12645
12943
  var DEFAULT_HANDLERS = mergeHandlers(
12646
12944
  SECRETS_HANDLERS,
@@ -13407,6 +13705,22 @@ var checkProjectFiles = (env) => [
13407
13705
  )
13408
13706
  ];
13409
13707
 
13708
+ // src/doctor/remotes.ts
13709
+ var checkRemoteCredentials = ({ root, runtime }) => {
13710
+ const remotes = remotesWithCredentials(runtime, root);
13711
+ if (remotes.length === 0) return [];
13712
+ const params2 = { remotes: remotes.join(", "), remote: remotes[0] ?? "origin" };
13713
+ return [
13714
+ finding(
13715
+ "secrets.remote_credential",
13716
+ "warn",
13717
+ "doctor.secrets.remote_credential",
13718
+ params2,
13719
+ "doctor.secrets.fix_remote_credential"
13720
+ )
13721
+ ];
13722
+ };
13723
+
13410
13724
  // src/doctor/run.ts
13411
13725
  var DOCTOR_SCHEMA = "finitemonkey/doctor@1";
13412
13726
  var CHECKS = [
@@ -13422,6 +13736,7 @@ var CHECKS = [
13422
13736
  checkInstructions,
13423
13737
  checkProjectFiles,
13424
13738
  checkLineEndings,
13739
+ checkRemoteCredentials,
13425
13740
  checkAgentsFile,
13426
13741
  checkHooks
13427
13742
  ];
@@ -13449,7 +13764,7 @@ var toItem = (ctx, finding3) => ({
13449
13764
  detail: ctx.t(finding3.messageKey, finding3.params),
13450
13765
  ...finding3.fixKey === void 0 ? {} : { fix: ctx.t(finding3.fixKey, finding3.params) }
13451
13766
  });
13452
- var count2 = (items, status) => items.filter((item) => item.status === status).length;
13767
+ var count3 = (items, status) => items.filter((item) => item.status === status).length;
13453
13768
  var hookItems = (ctx, findings) => findings.flatMap(
13454
13769
  (finding3) => finding3.hook === void 0 ? [] : [
13455
13770
  {
@@ -13465,7 +13780,7 @@ var buildReport2 = (ctx, findings) => {
13465
13780
  schema: DOCTOR_SCHEMA,
13466
13781
  checks,
13467
13782
  hooks: hookItems(ctx, findings),
13468
- summary: { ok: count2(checks, "ok"), warn: count2(checks, "warn"), fail: count2(checks, "fail") }
13783
+ summary: { ok: count3(checks, "ok"), warn: count3(checks, "warn"), fail: count3(checks, "fail") }
13469
13784
  };
13470
13785
  };
13471
13786
 
@@ -13782,7 +14097,7 @@ var resultOf = (ctx, checked) => {
13782
14097
  const problem4 = gitProblem("merge_unanalyzable", "git.error.merge_unanalyzable", detail, "git status");
13783
14098
  return failureFrom(ctx.t, { ...problem4, exitCode: ExitCode.Usage, data }, human2);
13784
14099
  }
13785
- const lost = checked.reduce((count3, item) => count3 + item.report.findings.length, 0);
14100
+ const lost = checked.reduce((count4, item) => count4 + item.report.findings.length, 0);
13786
14101
  if (lost === 0) return success(data, human2);
13787
14102
  return failureFrom(
13788
14103
  ctx.t,
@@ -14313,17 +14628,17 @@ var rulesFor = (locale) => {
14313
14628
  var branchFor = (node, value, locale) => {
14314
14629
  const { branches } = node;
14315
14630
  if (node.kind === "select") return branches[String(value)] ?? branches["other"];
14316
- const count3 = typeof value === "number" ? value : Number(value);
14317
- if (!Number.isFinite(count3)) return branches["other"];
14318
- return branches[`=${String(count3)}`] ?? branches[rulesFor(locale).select(count3)] ?? branches["other"];
14631
+ const count4 = typeof value === "number" ? value : Number(value);
14632
+ if (!Number.isFinite(count4)) return branches["other"];
14633
+ return branches[`=${String(count4)}`] ?? branches[rulesFor(locale).select(count4)] ?? branches["other"];
14319
14634
  };
14320
- var render = (nodes, params2, locale, count3) => nodes.map((node) => {
14635
+ var render = (nodes, params2, locale, count4) => nodes.map((node) => {
14321
14636
  if (node.kind === "text") return node.text;
14322
- if (node.kind === "count") return count3 === void 0 ? "#" : String(count3);
14637
+ if (node.kind === "count") return count4 === void 0 ? "#" : String(count4);
14323
14638
  const value = params2[node.name];
14324
14639
  if (node.kind === "arg") return value === void 0 ? `{${node.name}}` : String(value);
14325
14640
  const branch = branchFor(node, value, locale) ?? [];
14326
- return render(branch, params2, locale, node.kind === "plural" ? value : count3);
14641
+ return render(branch, params2, locale, node.kind === "plural" ? value : count4);
14327
14642
  }).join("");
14328
14643
  var formatMessage = (source, params2, locale) => render(parseMessage(source), params2, locale, void 0);
14329
14644
  var walk = (nodes, visit2) => {
@@ -14353,7 +14668,7 @@ var choicesOf = (source) => {
14353
14668
  var requiredPluralBranches = (locale) => {
14354
14669
  const rules = rulesFor(locale);
14355
14670
  const seen = /* @__PURE__ */ new Set(["other"]);
14356
- for (let count3 = 0; count3 <= 1e3; count3 += 1) seen.add(rules.select(count3));
14671
+ for (let count4 = 0; count4 <= 1e3; count4 += 1) seen.add(rules.select(count4));
14357
14672
  return [...seen].sort();
14358
14673
  };
14359
14674
 
@@ -14666,6 +14981,7 @@ var lookBack = (runtime, root, env) => {
14666
14981
 
14667
14982
  // src/install/diff.ts
14668
14983
  var MAX_DIFF_LINES = 120;
14984
+ var MAX_LCS_CELLS = 4e6;
14669
14985
  var table = (a, b) => {
14670
14986
  const rows = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
14671
14987
  for (let i = a.length - 1; i >= 0; i -= 1) {
@@ -14700,8 +15016,27 @@ var walk2 = (a, b) => {
14700
15016
  return ops;
14701
15017
  };
14702
15018
  var splitLines = (text6) => text6 === "" ? [] : text6.replace(/\r?\n$/, "").split(/\r?\n/);
15019
+ var sharedPrefix = (a, b) => {
15020
+ let count4 = 0;
15021
+ while (count4 < a.length && count4 < b.length && a[count4] === b[count4]) count4 += 1;
15022
+ return count4;
15023
+ };
15024
+ var sharedSuffix = (a, b, prefix) => {
15025
+ let count4 = 0;
15026
+ while (count4 < a.length - prefix && count4 < b.length - prefix && a[a.length - 1 - count4] === b[b.length - 1 - count4])
15027
+ count4 += 1;
15028
+ return count4;
15029
+ };
15030
+ var changedOps = (a, b) => {
15031
+ const prefix = sharedPrefix(a, b);
15032
+ const suffix = sharedSuffix(a, b, prefix);
15033
+ const left = a.slice(prefix, a.length - suffix);
15034
+ const right = b.slice(prefix, b.length - suffix);
15035
+ if (left.length * right.length <= MAX_LCS_CELLS) return walk2(left, right).filter(([op]) => op !== "=");
15036
+ return [...left.map((line2) => ["-", line2]), ...right.map((line2) => ["+", line2])];
15037
+ };
14703
15038
  var lineDiff = (path, before, after) => {
14704
- const changes = walk2(splitLines(before), splitLines(after)).filter(([op]) => op !== "=");
15039
+ const changes = changedOps(splitLines(before), splitLines(after));
14705
15040
  if (changes.length === 0) return [];
14706
15041
  const lines3 = changes.map(([op, line2]) => `${op} ${line2}`);
14707
15042
  const shown3 = lines3.slice(0, MAX_DIFF_LINES);
@@ -15274,7 +15609,7 @@ var humanOf2 = (ctx, data) => [
15274
15609
  ];
15275
15610
  var resultOf3 = (ctx, data) => {
15276
15611
  const human2 = humanOf2(ctx, data);
15277
- const failing = data.summary.new + data.summary.expired;
15612
+ const failing = data.findings.filter((item) => item.status !== "known" && item.severity !== "warning").length;
15278
15613
  if (failing === 0) return success(data, human2);
15279
15614
  return failureFrom(
15280
15615
  ctx.t,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "finitemonkey",
3
- "version": "0.1.0-next.14",
3
+ "version": "0.1.0-next.15",
4
4
  "description": "Claude Code hooks that stop a coding agent from saying done before the tests pass: a machine-checked definition of done, plus guardrails for secrets, git safety and production. Pre-release.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -68,6 +68,7 @@
68
68
  "budgets": "tsx scripts/budgets.ts",
69
69
  "docs": "tsx scripts/build-docs.ts",
70
70
  "docs:check": "tsc -p scripts && tsx scripts/build-docs.ts --check",
71
+ "e2e:claude": "node --import tsx --test --test-concurrency=1 test/e2e/claude-stop.e2e.ts",
71
72
  "plugin:sync": "node scripts/plugin-sync.mjs",
72
73
  "plugin:validate": "node scripts/plugin-validate.mjs",
73
74
  "version": "node scripts/plugin-sync.mjs"