@codyswann/lisa 2.342.0 → 2.342.2

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 (69) hide show
  1. package/all/copy-overwrite/scripts/lisa-floor-collisions.mjs +38 -6
  2. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +44 -13
  3. package/all/create-only/scripts/remote-agent-aws-setup.sh +8 -1
  4. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.js +8 -7
  6. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  7. package/dist/opencode/plugin-templates/parity-safety-net.sh +44 -13
  8. package/package.json +1 -1
  9. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  10. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  11. package/plugins/lisa/hooks/parity-safety-net.sh +44 -13
  12. package/plugins/lisa/hooks/threshold-ratchet.mjs +39 -5
  13. package/plugins/lisa-agy/hooks/parity-safety-net.sh +44 -13
  14. package/plugins/lisa-agy/plugin.json +1 -1
  15. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  18. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +44 -13
  22. package/plugins/lisa-copilot/hooks/threshold-ratchet.mjs +39 -5
  23. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +44 -13
  25. package/plugins/lisa-cursor/hooks/threshold-ratchet.mjs +39 -5
  26. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo-agy/plugin.json +1 -1
  29. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  39. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  44. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  49. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails-agy/plugin.json +1 -1
  54. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  59. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  64. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/src/base/hooks/parity-safety-net.sh +44 -13
  67. package/plugins/src/base/hooks/threshold-ratchet.mjs +39 -5
  68. package/rails/copy-overwrite/scripts/check-threshold-ratchet.mjs +39 -5
  69. package/typescript/copy-overwrite/scripts/check-threshold-ratchet.mjs +39 -5
@@ -56,6 +56,15 @@ const GIT = GIT_LOCATIONS.find(candidate => fs.existsSync(candidate)) ?? "git";
56
56
 
57
57
  /** Git flag shared by every changed-file listing. */
58
58
  const NAME_ONLY = "--name-only";
59
+ /**
60
+ * Suppress rename detection when discovering changed files.
61
+ *
62
+ * With it on, `git diff --name-only` reports a rename as the destination path
63
+ * only. Moving a watched threshold file to an unwatched path therefore lists
64
+ * one name the ratchet does not watch, and the loosening at the old path is
65
+ * never compared. Both sides have to be visible for the gate to mean anything.
66
+ */
67
+ const NO_RENAMES = "--no-renames";
59
68
 
60
69
  /**
61
70
  * Run git, returning stdout or null on any failure.
@@ -87,7 +96,7 @@ function git(args, cwd) {
87
96
  */
88
97
  function resolvePlan(mode, root, baseRef, onlyFiles) {
89
98
  if (mode === "staged") {
90
- const diff = git(["diff", "--cached", NAME_ONLY], root);
99
+ const diff = git(["diff", "--cached", NAME_ONLY, NO_RENAMES], root);
91
100
  if (diff === null) return null;
92
101
  return {
93
102
  files: diff.split("\n").filter(Boolean),
@@ -99,7 +108,7 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
99
108
  if (!baseRef) return null;
100
109
  const mergeBase = git(["merge-base", baseRef, "HEAD"], root)?.trim();
101
110
  if (!mergeBase) return null;
102
- const diff = git(["diff", NAME_ONLY, mergeBase, "HEAD"], root);
111
+ const diff = git(["diff", NAME_ONLY, NO_RENAMES, mergeBase, "HEAD"], root);
103
112
  if (diff === null) return null;
104
113
  return {
105
114
  files: diff.split("\n").filter(Boolean),
@@ -107,7 +116,7 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
107
116
  readCurrent: f => git(["show", `HEAD:${f}`], root),
108
117
  };
109
118
  }
110
- const diff = git(["diff", NAME_ONLY, "HEAD"], root);
119
+ const diff = git(["diff", NAME_ONLY, NO_RENAMES, "HEAD"], root);
111
120
  if (diff === null) return null;
112
121
  return {
113
122
  files: onlyFiles ?? diff.split("\n").filter(Boolean),
@@ -122,6 +131,31 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
122
131
  };
123
132
  }
124
133
 
134
+ /**
135
+ * Decide the exit code when the ratchet cannot determine what changed.
136
+ *
137
+ * `staged` and `base` are enforcement gates — pre-commit and CI. A gate that
138
+ * cannot see the change set has not found the change set clean, and returning 0
139
+ * reports exactly that. It fails closed, loudly.
140
+ *
141
+ * `hook` is advisory feedback to an agent mid-edit, fires on every tool call,
142
+ * and blocks nothing downstream. Failing it closed on a transient git hiccup
143
+ * would turn a hint into an outage, so it stays permissive — but still says so.
144
+ * @param {"hook"|"staged"|"base"} mode Comparison mode
145
+ * @param {string} reason What could not be determined
146
+ * @returns {number} Process exit code
147
+ */
148
+ function undeterminable(mode, reason) {
149
+ process.stderr.write(
150
+ `threshold-ratchet: ${reason}; the ratchet could not run in ${mode} mode\n`
151
+ );
152
+ if (mode === "hook") return 0;
153
+ process.stderr.write(
154
+ "threshold-ratchet: failing closed — an enforcement gate that cannot read the change set has not verified it\n"
155
+ );
156
+ return 1;
157
+ }
158
+
125
159
  /**
126
160
  * Run the ratchet for a mode, print the report, and return the exit code.
127
161
  * @param {"hook"|"staged"|"base"} mode Comparison mode
@@ -131,9 +165,9 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
131
165
  */
132
166
  function run(mode, baseRef, onlyFiles) {
133
167
  const root = git(["rev-parse", "--show-toplevel"])?.trim();
134
- if (!root) return 0;
168
+ if (!root) return undeterminable(mode, "not a git repository");
135
169
  const plan = resolvePlan(mode, root, baseRef, onlyFiles);
136
- if (!plan) return 0;
170
+ if (!plan) return undeterminable(mode, "could not resolve the changed files");
137
171
 
138
172
  const watched = plan.files.filter(f => familyFor(f));
139
173
  if (watched.length === 0) return 0;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Expo and React Native-specific skills, agents, rules, and MCP servers.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Harper/Fabric-specific Lisa rules for TypeScript component apps.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "NestJS-specific skills and migration write-protection hooks.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Ruby on Rails-specific skills and hooks for RuboCop and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "TypeScript-specific hooks for formatting, linting, and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.342.0",
3
+ "version": "2.342.2",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -172,16 +172,6 @@ case "$command_str" in
172
172
  ;;
173
173
  esac
174
174
 
175
- # matches / matches_cs run an ERE against the guarded command text. matches is
176
- # case-insensitive (the default for these guards); matches_cs is case-SENSITIVE
177
- # for guards where flag case is meaningful (`git branch -d` vs `-D`).
178
- matches() {
179
- printf '%s' "$command_for_guards" | grep -Eiq -- "$1"
180
- }
181
- matches_cs() {
182
- printf '%s' "$command_for_guards" | grep -Eq -- "$1"
183
- }
184
-
185
175
  # Normalize bash line-continuations (a trailing backslash + newline → space)
186
176
  # before segmenting the command. Without this, "git push --force origin
187
177
  # \<newline>main" splits into a segment matching --force but not `main`, letting a
@@ -190,6 +180,28 @@ matches_cs() {
190
180
  normalized_command_str="$(printf '%s' "$command_for_guards" \
191
181
  | awk '{ if (sub(/\\$/, "")) printf "%s ", $0; else print }')"
192
182
 
183
+ # matches / matches_cs run an ERE against the guarded command text. matches is
184
+ # case-insensitive (the default for these guards); matches_cs is case-SENSITIVE
185
+ # for guards where flag case is meaningful (`git branch -d` vs `-D`).
186
+ #
187
+ # Both read the NORMALIZED text. They used to read $command_for_guards, so only
188
+ # the two segment-walking guards (rm, git push) were protected against line
189
+ # continuations and every guard expressed through these helpers could be evaded
190
+ # by breaking the command across lines:
191
+ #
192
+ # git reset --hard \
193
+ # origin/main
194
+ #
195
+ # The patterns match on intermediate whitespace, and an embedded "\<newline>" is
196
+ # not whitespace to grep -E, so the anchor failed to span the break. Defined
197
+ # after the normalization above so the value exists when these are first called.
198
+ matches() {
199
+ printf '%s' "$normalized_command_str" | grep -Eiq -- "$1"
200
+ }
201
+ matches_cs() {
202
+ printf '%s' "$normalized_command_str" | grep -Eq -- "$1"
203
+ }
204
+
193
205
  # Shared ERE fragments. GIT_TOKENS walks over intermediate argv tokens without
194
206
  # crossing a statement separator, so a flag in a LATER statement can never be
195
207
  # attributed to an earlier git subcommand.
@@ -553,9 +565,28 @@ if [ -f "$rules_file" ]; then
553
565
  case "$rule" in
554
566
  '' | '#'*) continue ;;
555
567
  esac
556
- if printf '%s' "$command_for_guards" | grep -Eiq -- "$rule"; then
557
- block "matched a project custom safety rule (${rules_file##*/}): $rule"
558
- fi
568
+ # Normalized text, for the same reason every built-in guard uses it: a
569
+ # project rule must not be evadable by breaking the command across lines.
570
+ #
571
+ # `|| rule_status=$?` rather than a bare pipeline: this script runs under
572
+ # `set -e`, and a no-match grep exits 1, which would end the hook right
573
+ # here — before the remaining rules ran, and with an exit status that is
574
+ # not a refusal. The `||` puts the pipeline in a condition context, where
575
+ # a non-zero status is expected rather than fatal.
576
+ rule_status=0
577
+ printf '%s' "$normalized_command_str" | grep -Eiq -- "$rule" || rule_status=$?
578
+ case "$rule_status" in
579
+ 0) block "matched a project custom safety rule (${rules_file##*/}): $rule" ;;
580
+ 1) ;; # no match
581
+ # grep exits 2 on a malformed ERE. Treated as no-match before, so a typo
582
+ # in a project's rules file silently disabled that rule — a guard the
583
+ # project believed it had. Say so instead; still non-fatal, because one
584
+ # bad line must not take the other rules down with it.
585
+ *)
586
+ printf 'parity-safety-net: invalid regex in %s, rule NOT enforced: %s\n' \
587
+ "$rules_file" "$rule" >&2
588
+ ;;
589
+ esac
559
590
  done <"$rules_file"
560
591
  fi
561
592
 
@@ -56,6 +56,15 @@ const GIT = GIT_LOCATIONS.find(candidate => fs.existsSync(candidate)) ?? "git";
56
56
 
57
57
  /** Git flag shared by every changed-file listing. */
58
58
  const NAME_ONLY = "--name-only";
59
+ /**
60
+ * Suppress rename detection when discovering changed files.
61
+ *
62
+ * With it on, `git diff --name-only` reports a rename as the destination path
63
+ * only. Moving a watched threshold file to an unwatched path therefore lists
64
+ * one name the ratchet does not watch, and the loosening at the old path is
65
+ * never compared. Both sides have to be visible for the gate to mean anything.
66
+ */
67
+ const NO_RENAMES = "--no-renames";
59
68
 
60
69
  /**
61
70
  * Run git, returning stdout or null on any failure.
@@ -87,7 +96,7 @@ function git(args, cwd) {
87
96
  */
88
97
  function resolvePlan(mode, root, baseRef, onlyFiles) {
89
98
  if (mode === "staged") {
90
- const diff = git(["diff", "--cached", NAME_ONLY], root);
99
+ const diff = git(["diff", "--cached", NAME_ONLY, NO_RENAMES], root);
91
100
  if (diff === null) return null;
92
101
  return {
93
102
  files: diff.split("\n").filter(Boolean),
@@ -99,7 +108,7 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
99
108
  if (!baseRef) return null;
100
109
  const mergeBase = git(["merge-base", baseRef, "HEAD"], root)?.trim();
101
110
  if (!mergeBase) return null;
102
- const diff = git(["diff", NAME_ONLY, mergeBase, "HEAD"], root);
111
+ const diff = git(["diff", NAME_ONLY, NO_RENAMES, mergeBase, "HEAD"], root);
103
112
  if (diff === null) return null;
104
113
  return {
105
114
  files: diff.split("\n").filter(Boolean),
@@ -107,7 +116,7 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
107
116
  readCurrent: f => git(["show", `HEAD:${f}`], root),
108
117
  };
109
118
  }
110
- const diff = git(["diff", NAME_ONLY, "HEAD"], root);
119
+ const diff = git(["diff", NAME_ONLY, NO_RENAMES, "HEAD"], root);
111
120
  if (diff === null) return null;
112
121
  return {
113
122
  files: onlyFiles ?? diff.split("\n").filter(Boolean),
@@ -122,6 +131,31 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
122
131
  };
123
132
  }
124
133
 
134
+ /**
135
+ * Decide the exit code when the ratchet cannot determine what changed.
136
+ *
137
+ * `staged` and `base` are enforcement gates — pre-commit and CI. A gate that
138
+ * cannot see the change set has not found the change set clean, and returning 0
139
+ * reports exactly that. It fails closed, loudly.
140
+ *
141
+ * `hook` is advisory feedback to an agent mid-edit, fires on every tool call,
142
+ * and blocks nothing downstream. Failing it closed on a transient git hiccup
143
+ * would turn a hint into an outage, so it stays permissive — but still says so.
144
+ * @param {"hook"|"staged"|"base"} mode Comparison mode
145
+ * @param {string} reason What could not be determined
146
+ * @returns {number} Process exit code
147
+ */
148
+ function undeterminable(mode, reason) {
149
+ process.stderr.write(
150
+ `threshold-ratchet: ${reason}; the ratchet could not run in ${mode} mode\n`
151
+ );
152
+ if (mode === "hook") return 0;
153
+ process.stderr.write(
154
+ "threshold-ratchet: failing closed — an enforcement gate that cannot read the change set has not verified it\n"
155
+ );
156
+ return 1;
157
+ }
158
+
125
159
  /**
126
160
  * Run the ratchet for a mode, print the report, and return the exit code.
127
161
  * @param {"hook"|"staged"|"base"} mode Comparison mode
@@ -131,9 +165,9 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
131
165
  */
132
166
  function run(mode, baseRef, onlyFiles) {
133
167
  const root = git(["rev-parse", "--show-toplevel"])?.trim();
134
- if (!root) return 0;
168
+ if (!root) return undeterminable(mode, "not a git repository");
135
169
  const plan = resolvePlan(mode, root, baseRef, onlyFiles);
136
- if (!plan) return 0;
170
+ if (!plan) return undeterminable(mode, "could not resolve the changed files");
137
171
 
138
172
  const watched = plan.files.filter(f => familyFor(f));
139
173
  if (watched.length === 0) return 0;