harness-everything 0.5.3 → 0.6.1

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.
@@ -20,15 +20,15 @@ const ALWAYS_ASK_POLICY = 'always-ask';
20
20
  // Fail-safe defaults are used only when the configured table is missing or
21
21
  // invalid. A valid-but-empty table stays empty so CI can catch policy erasure.
22
22
  const BUILTIN_RULES = [
23
- ['git-force-push', ['Bash', 'PowerShell'], '\\bgit\\s+push\\b[\\s\\S]*(?:--force(?:-with-lease)?|(?:^|\\s)-f(?:\\s|$))', 'i', 'Force-pushing can rewrite remote history.', 'always'],
24
- ['git-reset-hard', ['Bash', 'PowerShell'], '\\bgit\\s+reset\\s+--hard\\b', 'i', 'git reset --hard can discard local work.', 'always'],
25
- ['git-clean-force-delete', ['Bash', 'PowerShell'], '\\bgit\\s+clean\\b(?=[^\\r\\n]*\\s-(?:[^\\s]*f|f\\b))(?=[^\\r\\n]*\\s-(?:[^\\s]*d|d\\b))', 'i', 'git clean with force and directory deletion can remove untracked work.', 'always'],
26
- ['recursive-delete', ['Bash'], '(?:^|[;&|\\n]\\s*)rm\\s+(?=[^\\r\\n;&|]*-(?:[^\\s]*r|r\\b))(?=[^\\r\\n;&|]*-(?:[^\\s]*f|f\\b))', 'i', 'Recursive forced deletion outside approved scratch space is destructive.', 'outside-scratch'],
27
- ['powershell-recursive-force-delete', ['Bash', 'PowerShell'], '\\bRemove-Item\\b(?=[^\\r\\n;|]*-(?:Recurse|r)\\b)(?=[^\\r\\n;|]*-(?:Force|fo)\\b)', 'i', 'PowerShell recursive forced deletion outside approved scratch space is destructive.', 'outside-scratch'],
28
- ['sql-destructive-ddl', ['Bash', 'PowerShell'], '\\b(?:DROP\\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE(?:\\s+TABLE)?)\\b', 'i', 'Destructive SQL DDL can irreversibly remove production data or schema state.', 'always'],
29
- ['package-publish', ['Bash', 'PowerShell'], '\\b(?:npm|pnpm|yarn|cargo|twine)\\s+publish\\b', 'i', 'Publishing creates an external package/release side effect.', 'always'],
30
- ['github-release-create', ['Bash', 'PowerShell'], '\\bgh\\s+release\\s+create\\b', 'i', 'Creating a GitHub release is an external irreversible publication action.', 'always'],
31
- ['production-deploy', ['Bash', 'PowerShell'], '\\b(?:kubectl\\s+apply|helm\\s+(?:upgrade|install)|terraform\\s+apply|vercel\\s+deploy|fly\\s+deploy|gcloud\\s+run\\s+deploy|(?:npm|pnpm|yarn)\\s+(?:run\\s+)?deploy|deploy\\s+to\\s+(?:prod|production|live))\\b', 'i', 'Deployment changes an external environment and requires explicit approval.', 'always'],
23
+ ['git-force-push', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*push\\b(?=[^\\r\\n;&|]*(?:--force(?:-with-lease)?\\b|-[A-Za-z]*f[A-Za-z]*\\b|\\s\\+[^\\s;&|]+))', 'i', 'Force-pushing can rewrite remote history.', 'always'],
24
+ ['git-reset-hard', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*reset\\b[^\\r\\n;&|]*--hard\\b', 'i', 'git reset --hard can discard local work.', 'always'],
25
+ ['git-clean-force-delete', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*clean\\b(?=[^\\r\\n;&|]*\\s-(?:[^\\s]*f|f\\b))(?=[^\\r\\n;&|]*\\s-(?:[^\\s]*d|d\\b))', 'i', 'git clean with force and directory deletion can remove untracked work.', 'always'],
26
+ ['recursive-delete', ['Bash'], '(?:^|[;&|\\n]\\s*)(?:(?:bash|sh)\\s+(?:-\\S+\\s+)*-c\\s+[\\"\']\\s*)?(?:sudo(?:\\s+-\\S+)*\\s+)?rm\\s+(?=[^\\r\\n;&|]*-(?:[^\\s]*r|r\\b))(?=[^\\r\\n;&|]*-(?:[^\\s]*f|f\\b))', 'i', 'Recursive forced deletion outside approved scratch space is destructive.', 'outside-scratch'],
27
+ ['powershell-recursive-force-delete', ['PowerShell'], '(?:^|[;&|\\n]\\s*)(?:Remove-Item|ri|rm)\\b(?=[^\\r\\n;|]*-(?:Recurse|r)\\b)(?=[^\\r\\n;|]*-(?:Force|fo)\\b)', 'i', 'PowerShell recursive forced deletion outside approved scratch space is destructive.', 'outside-scratch'],
28
+ ['sql-destructive-ddl', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)(?:(?:psql|mysql|sqlcmd|sqlite3)\\b[^\\r\\n;&|]*)?\\b(?:DROP\\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE(?:\\s+TABLE)?)\\b', 'i', 'Destructive SQL DDL can irreversibly remove production data or schema state.', 'always'],
29
+ ['package-publish', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)(?:sudo\\s+)?(?:npm|pnpm|yarn|cargo|twine)\\s+publish\\b', 'i', 'Publishing creates an external package/release side effect.', 'always'],
30
+ ['github-release-create', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)gh\\s+release\\s+create\\b', 'i', 'Creating a GitHub release is an external irreversible publication action.', 'always'],
31
+ ['production-deploy', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)(?:kubectl\\s+apply|helm\\s+(?:upgrade|install)|terraform\\s+apply|vercel\\s+deploy|fly\\s+deploy|gcloud\\s+run\\s+deploy|(?:npm|pnpm|yarn)\\s+(?:run\\s+)?deploy|deploy\\s+to\\s+(?:prod|production|live))\\b', 'i', 'Deployment changes an external environment and requires explicit approval.', 'always'],
32
32
  ].map(([id, tools, pattern, flags, reason, scope]) => ({ id, tools, pattern, flags, reason, scope }));
33
33
 
34
34
  function hashExact(value) {
@@ -153,9 +153,15 @@ function deleteTargets(command, ruleId) {
153
153
  const tails = [];
154
154
  let regex = null;
155
155
  if (ruleId === 'recursive-delete') {
156
- regex = /(?:^|[;&|\n]\s*)rm\s+([^;&|\n]+)/ig;
156
+ regex = /(?:^|[;&|\n]\s*)(?:sudo(?:\s+-\S+)*\s+)?rm\s+([^;&|\n]+)/ig;
157
+ const wrapper = /(?:^|[;&|\n]\s*)(?:bash|sh)\s+(?:-\S+\s+)*-c\s+(["'])([\s\S]*?)\1/ig;
158
+ let wrapped;
159
+ while ((wrapped = wrapper.exec(text)) !== null) {
160
+ tails.push(...deleteTargets(wrapped[2], ruleId));
161
+ if (wrapped[0].length === 0) wrapper.lastIndex++;
162
+ }
157
163
  } else if (ruleId === 'powershell-recursive-force-delete') {
158
- regex = /\bRemove-Item\b([^;|\n]+)/ig;
164
+ regex = /(?:^|[;|\n]\s*)(?:Remove-Item|ri|rm)\b([^;|\n]+)/ig;
159
165
  }
160
166
  if (!regex) return [];
161
167
 
@@ -190,6 +196,10 @@ function isWithin(target, root) {
190
196
 
191
197
  function scratchRoots(payload, sessionDir) {
192
198
  const roots = [os.tmpdir(), path.join(sessionDir, 'scratch')];
199
+ // Git Bash/MSYS exposes the Windows temp area as /tmp (and sometimes
200
+ // /var/tmp). Treat those aliases as scratch on Windows instead of gating a
201
+ // harmless temp cleanup merely because the host path syntax differs.
202
+ if (process.platform === 'win32') roots.push('/tmp', '/var/tmp');
193
203
  const declared = payload && (payload.scratchpad_path || payload.scratchpadPath || (payload.harness && payload.harness.scratchpadPath));
194
204
  if (declared) roots.push(declared);
195
205
  if (process.env.HARNESS_SCRATCH_DIR) roots.push(process.env.HARNESS_SCRATCH_DIR);
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Atomic Commit Check (PostToolUse: apply_patch)
4
- * PostToolUse can't block - the edit/write already happened. So this counts
5
- * apply_patch calls since the last commit and, once the count crosses a
6
- * threshold, feeds a strong reminder back to Claude (exit 2) to commit the
7
- * completed chunk before piling on more changes. Resets whenever HEAD moves.
3
+ * Atomic Commit Check (PostToolUse: Edit/Write/apply_patch)
4
+ * PostToolUse can't block - the edit/write already happened. Count only edits
5
+ * whose targets resolve inside the current workspace. Once the count crosses
6
+ * the threshold, emit a strong reminder only when the Git worktree actually
7
+ * has uncommitted changes. Resets whenever HEAD moves.
8
8
  * Fails open on any error - this is a nudge, never a hard stop.
9
9
  */
10
10
  const { execSync } = require('child_process');
@@ -15,6 +15,90 @@ const { getWorkspaceRoot, getSessionDir } = require('./lib/harness-state');
15
15
  const THRESHOLD = 6;
16
16
  const RENUDGE_EVERY = 3;
17
17
 
18
+ function toolNameOf(payload) {
19
+ return String((payload && (payload.tool_name || payload.toolName)) || '');
20
+ }
21
+
22
+ function toolInputOf(payload) {
23
+ return (payload && (payload.tool_input || payload.toolInput || payload.input)) || {};
24
+ }
25
+
26
+ function pathApiFor(value) {
27
+ return /^[A-Za-z]:[\\/]/.test(String(value || '')) ? path.win32 : path;
28
+ }
29
+
30
+ function canonicalPath(value) {
31
+ const text = String(value || '');
32
+ if (!text) return null;
33
+ const api = pathApiFor(text);
34
+ const resolved = api.resolve(text);
35
+
36
+ // realpath() is necessary on macOS where /var is a symlink to /private/var.
37
+ // New Write targets may not exist yet, so canonicalize the nearest existing
38
+ // ancestor and append the missing path segments without requiring creation.
39
+ const missing = [];
40
+ let cursor = resolved;
41
+ while (cursor && !fs.existsSync(cursor)) {
42
+ const parent = api.dirname(cursor);
43
+ if (!parent || parent === cursor) break;
44
+ missing.unshift(api.basename(cursor));
45
+ cursor = parent;
46
+ }
47
+ if (!cursor || !fs.existsSync(cursor)) return resolved;
48
+
49
+ let real = fs.realpathSync.native ? fs.realpathSync.native(cursor) : fs.realpathSync(cursor);
50
+ for (const segment of missing) real = api.join(real, segment);
51
+ return api.normalize(real);
52
+ }
53
+
54
+ function isWithin(target, root) {
55
+ if (!target || !root) return false;
56
+ const api = pathApiFor(target) === path.win32 || pathApiFor(root) === path.win32 ? path.win32 : path;
57
+ const resolvedTarget = canonicalPath(target);
58
+ const resolvedRoot = canonicalPath(root);
59
+ const relative = api.relative(resolvedRoot, resolvedTarget);
60
+ if (!relative) return true;
61
+ return relative !== '..' && !relative.startsWith(`..${api.sep}`) && !api.isAbsolute(relative);
62
+ }
63
+
64
+ function absoluteTarget(target, root) {
65
+ const text = String(target || '').trim();
66
+ if (!text) return null;
67
+ const api = pathApiFor(text) === path.win32 || pathApiFor(root) === path.win32 ? path.win32 : path;
68
+ return api.isAbsolute(text) ? api.normalize(text) : api.resolve(root, text);
69
+ }
70
+
71
+ function patchTargets(patchText) {
72
+ const targets = [];
73
+ const regex = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+?)\s*$/gm;
74
+ let match;
75
+ while ((match = regex.exec(String(patchText || ''))) !== null) targets.push(match[1]);
76
+ return targets;
77
+ }
78
+
79
+ function editTargets(payload) {
80
+ const tool = toolNameOf(payload);
81
+ const input = toolInputOf(payload);
82
+ if (tool === 'Edit' || tool === 'Write') {
83
+ const target = input.file_path || input.filePath || input.path;
84
+ return target ? [String(target)] : [];
85
+ }
86
+ if (tool === 'apply_patch') {
87
+ return patchTargets(input.patch || input.command || input.content || '');
88
+ }
89
+ return [];
90
+ }
91
+
92
+ function touchesWorkspace(payload, root) {
93
+ const targets = editTargets(payload);
94
+ if (targets.length === 0) return false;
95
+ return targets.some(target => isWithin(absoluteTarget(target, root), root));
96
+ }
97
+
98
+ function writeState(stateFile, state) {
99
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
100
+ }
101
+
18
102
  function main(payload) {
19
103
  try {
20
104
  const root = getWorkspaceRoot(payload);
@@ -33,17 +117,32 @@ function main(payload) {
33
117
  state.editCount = 0;
34
118
  }
35
119
 
120
+ // Host scratchpads, memory stores, temp files, and any other target outside
121
+ // the repository must not contribute to a repository commit reminder.
122
+ if (!touchesWorkspace(payload, root)) {
123
+ writeState(stateFile, state);
124
+ process.exit(0);
125
+ }
126
+
36
127
  state.editCount += 1;
37
- fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
128
+ writeState(stateFile, state);
38
129
 
39
130
  const overThreshold = state.editCount >= THRESHOLD;
40
131
  const onNudgeBeat = overThreshold && (state.editCount - THRESHOLD) % RENUDGE_EVERY === 0;
41
132
 
42
133
  if (onNudgeBeat) {
43
134
  const statusRaw = execSync('git status --porcelain', { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
44
- const changedFiles = statusRaw ? statusRaw.split('\n').length : 0;
45
- console.error(`[Atomic Commit Check] ${state.editCount} apply_patch calls since the last commit (${changedFiles} files currently changed).`);
46
- console.error(`If a logically complete chunk of work is done, commit it now before continuing - large uncommitted diffs are harder to review and harder to recover from.`);
135
+ if (!statusRaw) {
136
+ // Calls that produced no Git-visible change (or work already reverted)
137
+ // must not leave a stale counter that immediately re-nudges later.
138
+ state.editCount = 0;
139
+ writeState(stateFile, state);
140
+ process.exit(0);
141
+ }
142
+
143
+ const changedFiles = statusRaw.split('\n').length;
144
+ console.error(`[Atomic Commit Check] ${state.editCount} edits since the last commit (${changedFiles} files currently changed).`);
145
+ console.error('If a logically complete chunk of work is done, commit it now before continuing - large uncommitted diffs are harder to review and harder to recover from.');
47
146
  process.exit(2);
48
147
  }
49
148
 
@@ -66,3 +165,12 @@ process.stdin.on('error', () => {
66
165
  clearTimeout(timeout);
67
166
  main(null);
68
167
  });
168
+
169
+ module.exports = {
170
+ absoluteTarget,
171
+ canonicalPath,
172
+ editTargets,
173
+ isWithin,
174
+ patchTargets,
175
+ touchesWorkspace,
176
+ };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-enforcement",
3
3
  "description": "Repository inventory for the opencode enforcement plugin and its standalone helper",
4
- "version": "0.5.3",
4
+ "version": "0.6.1",
5
5
  "runtime": "index.mjs",
6
6
  "hooks": {
7
7
  "verify": "hooks/verify.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.5.3",
3
+ "version": "0.6.1",
4
4
  "description": "Harness OS: An Orchestrated Agent Operating System for AI-driven Development",
5
5
  "main": "harness-everything/scripts/bootstrap.js",
6
6
  "bin": {
@@ -40,6 +40,9 @@
40
40
  "eval:behavioral": "node behavioral-evals/run.js run",
41
41
  "eval:behavioral:validate": "node behavioral-evals/run.js validate",
42
42
  "eval:behavioral:plugin": "node behavioral-evals/run-with-plugin.js run",
43
+ "eval:opencode:hardlock-live": "node behavioral-evals/opencode-hardlock-live.js run",
44
+ "eval:paired": "node behavioral-evals/paired-benchmark.js run",
45
+ "eval:paired:summarize": "node behavioral-evals/paired-benchmark.js summarize",
43
46
  "test:ab": "node ci/ab-test-harness.js validate",
44
47
  "test:ab:run": "node ci/ab-test-harness.js run",
45
48
  "harness:reset": "node hooks/scripts/reset-circuit-breaker.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.5.3",
3
+ "version": "0.6.1",
4
4
  "description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
5
5
  "author": {
6
6
  "name": "Miya Daniel",
@@ -4,7 +4,7 @@
4
4
  {
5
5
  "id": "git-force-push",
6
6
  "tools": ["Bash", "PowerShell"],
7
- "pattern": "\\bgit\\s+push\\b[\\s\\S]*(?:--force(?:-with-lease)?|(?:^|\\s)-f(?:\\s|$))",
7
+ "pattern": "(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*push\\b(?=[^\\r\\n;&|]*(?:--force(?:-with-lease)?\\b|-[A-Za-z]*f[A-Za-z]*\\b|\\s\\+[^\\s;&|]+))",
8
8
  "flags": "i",
9
9
  "reason": "Force-pushing can rewrite remote history.",
10
10
  "scope": "always"
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "id": "git-reset-hard",
14
14
  "tools": ["Bash", "PowerShell"],
15
- "pattern": "\\bgit\\s+reset\\s+--hard\\b",
15
+ "pattern": "(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*reset\\b[^\\r\\n;&|]*--hard\\b",
16
16
  "flags": "i",
17
17
  "reason": "git reset --hard can discard local work.",
18
18
  "scope": "always"
@@ -20,7 +20,7 @@
20
20
  {
21
21
  "id": "git-clean-force-delete",
22
22
  "tools": ["Bash", "PowerShell"],
23
- "pattern": "\\bgit\\s+clean\\b(?=[^\\r\\n]*\\s-(?:[^\\s]*f|f\\b))(?=[^\\r\\n]*\\s-(?:[^\\s]*d|d\\b))",
23
+ "pattern": "(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*clean\\b(?=[^\\r\\n;&|]*\\s-(?:[^\\s]*f|f\\b))(?=[^\\r\\n;&|]*\\s-(?:[^\\s]*d|d\\b))",
24
24
  "flags": "i",
25
25
  "reason": "git clean with force and directory deletion can remove untracked work.",
26
26
  "scope": "always"
@@ -28,15 +28,15 @@
28
28
  {
29
29
  "id": "recursive-delete",
30
30
  "tools": ["Bash"],
31
- "pattern": "(?:^|[;&|\\n]\\s*)rm\\s+(?=[^\\r\\n;&|]*-(?:[^\\s]*r|r\\b))(?=[^\\r\\n;&|]*-(?:[^\\s]*f|f\\b))",
31
+ "pattern": "(?:^|[;&|\\n]\\s*)(?:(?:bash|sh)\\s+(?:-\\S+\\s+)*-c\\s+[\\\"']\\s*)?(?:sudo(?:\\s+-\\S+)*\\s+)?rm\\s+(?=[^\\r\\n;&|]*-(?:[^\\s]*r|r\\b))(?=[^\\r\\n;&|]*-(?:[^\\s]*f|f\\b))",
32
32
  "flags": "i",
33
33
  "reason": "Recursive forced deletion outside approved scratch space is destructive.",
34
34
  "scope": "outside-scratch"
35
35
  },
36
36
  {
37
37
  "id": "powershell-recursive-force-delete",
38
- "tools": ["Bash", "PowerShell"],
39
- "pattern": "\\bRemove-Item\\b(?=[^\\r\\n;|]*-(?:Recurse|r)\\b)(?=[^\\r\\n;|]*-(?:Force|fo)\\b)",
38
+ "tools": ["PowerShell"],
39
+ "pattern": "(?:^|[;&|\\n]\\s*)(?:Remove-Item|ri|rm)\\b(?=[^\\r\\n;|]*-(?:Recurse|r)\\b)(?=[^\\r\\n;|]*-(?:Force|fo)\\b)",
40
40
  "flags": "i",
41
41
  "reason": "PowerShell recursive forced deletion outside approved scratch space is destructive.",
42
42
  "scope": "outside-scratch"
@@ -44,7 +44,7 @@
44
44
  {
45
45
  "id": "sql-destructive-ddl",
46
46
  "tools": ["Bash", "PowerShell"],
47
- "pattern": "\\b(?:DROP\\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE(?:\\s+TABLE)?)\\b",
47
+ "pattern": "(?:^|[;&|\\n]\\s*)(?:(?:psql|mysql|sqlcmd|sqlite3)\\b[^\\r\\n;&|]*)?\\b(?:DROP\\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE(?:\\s+TABLE)?)\\b",
48
48
  "flags": "i",
49
49
  "reason": "Destructive SQL DDL can irreversibly remove production data or schema state.",
50
50
  "scope": "always"
@@ -52,7 +52,7 @@
52
52
  {
53
53
  "id": "package-publish",
54
54
  "tools": ["Bash", "PowerShell"],
55
- "pattern": "\\b(?:npm|pnpm|yarn|cargo|twine)\\s+publish\\b",
55
+ "pattern": "(?:^|[;&|\\n]\\s*)(?:sudo\\s+)?(?:npm|pnpm|yarn|cargo|twine)\\s+publish\\b",
56
56
  "flags": "i",
57
57
  "reason": "Publishing creates an external package/release side effect.",
58
58
  "scope": "always"
@@ -60,7 +60,7 @@
60
60
  {
61
61
  "id": "github-release-create",
62
62
  "tools": ["Bash", "PowerShell"],
63
- "pattern": "\\bgh\\s+release\\s+create\\b",
63
+ "pattern": "(?:^|[;&|\\n]\\s*)gh\\s+release\\s+create\\b",
64
64
  "flags": "i",
65
65
  "reason": "Creating a GitHub release is an external irreversible publication action.",
66
66
  "scope": "always"
@@ -68,7 +68,7 @@
68
68
  {
69
69
  "id": "production-deploy",
70
70
  "tools": ["Bash", "PowerShell"],
71
- "pattern": "\\b(?:kubectl\\s+apply|helm\\s+(?:upgrade|install)|terraform\\s+apply|vercel\\s+deploy|fly\\s+deploy|gcloud\\s+run\\s+deploy|(?:npm|pnpm|yarn)\\s+(?:run\\s+)?deploy|deploy\\s+to\\s+(?:prod|production|live))\\b",
71
+ "pattern": "(?:^|[;&|\\n]\\s*)(?:kubectl\\s+apply|helm\\s+(?:upgrade|install)|terraform\\s+apply|vercel\\s+deploy|fly\\s+deploy|gcloud\\s+run\\s+deploy|(?:npm|pnpm|yarn)\\s+(?:run\\s+)?deploy|deploy\\s+to\\s+(?:prod|production|live))\\b",
72
72
  "flags": "i",
73
73
  "reason": "Deployment changes an external environment and requires explicit approval.",
74
74
  "scope": "always"
@@ -20,15 +20,15 @@ const ALWAYS_ASK_POLICY = 'always-ask';
20
20
  // Fail-safe defaults are used only when the configured table is missing or
21
21
  // invalid. A valid-but-empty table stays empty so CI can catch policy erasure.
22
22
  const BUILTIN_RULES = [
23
- ['git-force-push', ['Bash', 'PowerShell'], '\\bgit\\s+push\\b[\\s\\S]*(?:--force(?:-with-lease)?|(?:^|\\s)-f(?:\\s|$))', 'i', 'Force-pushing can rewrite remote history.', 'always'],
24
- ['git-reset-hard', ['Bash', 'PowerShell'], '\\bgit\\s+reset\\s+--hard\\b', 'i', 'git reset --hard can discard local work.', 'always'],
25
- ['git-clean-force-delete', ['Bash', 'PowerShell'], '\\bgit\\s+clean\\b(?=[^\\r\\n]*\\s-(?:[^\\s]*f|f\\b))(?=[^\\r\\n]*\\s-(?:[^\\s]*d|d\\b))', 'i', 'git clean with force and directory deletion can remove untracked work.', 'always'],
26
- ['recursive-delete', ['Bash'], '(?:^|[;&|\\n]\\s*)rm\\s+(?=[^\\r\\n;&|]*-(?:[^\\s]*r|r\\b))(?=[^\\r\\n;&|]*-(?:[^\\s]*f|f\\b))', 'i', 'Recursive forced deletion outside approved scratch space is destructive.', 'outside-scratch'],
27
- ['powershell-recursive-force-delete', ['Bash', 'PowerShell'], '\\bRemove-Item\\b(?=[^\\r\\n;|]*-(?:Recurse|r)\\b)(?=[^\\r\\n;|]*-(?:Force|fo)\\b)', 'i', 'PowerShell recursive forced deletion outside approved scratch space is destructive.', 'outside-scratch'],
28
- ['sql-destructive-ddl', ['Bash', 'PowerShell'], '\\b(?:DROP\\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE(?:\\s+TABLE)?)\\b', 'i', 'Destructive SQL DDL can irreversibly remove production data or schema state.', 'always'],
29
- ['package-publish', ['Bash', 'PowerShell'], '\\b(?:npm|pnpm|yarn|cargo|twine)\\s+publish\\b', 'i', 'Publishing creates an external package/release side effect.', 'always'],
30
- ['github-release-create', ['Bash', 'PowerShell'], '\\bgh\\s+release\\s+create\\b', 'i', 'Creating a GitHub release is an external irreversible publication action.', 'always'],
31
- ['production-deploy', ['Bash', 'PowerShell'], '\\b(?:kubectl\\s+apply|helm\\s+(?:upgrade|install)|terraform\\s+apply|vercel\\s+deploy|fly\\s+deploy|gcloud\\s+run\\s+deploy|(?:npm|pnpm|yarn)\\s+(?:run\\s+)?deploy|deploy\\s+to\\s+(?:prod|production|live))\\b', 'i', 'Deployment changes an external environment and requires explicit approval.', 'always'],
23
+ ['git-force-push', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*push\\b(?=[^\\r\\n;&|]*(?:--force(?:-with-lease)?\\b|-[A-Za-z]*f[A-Za-z]*\\b|\\s\\+[^\\s;&|]+))', 'i', 'Force-pushing can rewrite remote history.', 'always'],
24
+ ['git-reset-hard', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*reset\\b[^\\r\\n;&|]*--hard\\b', 'i', 'git reset --hard can discard local work.', 'always'],
25
+ ['git-clean-force-delete', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*clean\\b(?=[^\\r\\n;&|]*\\s-(?:[^\\s]*f|f\\b))(?=[^\\r\\n;&|]*\\s-(?:[^\\s]*d|d\\b))', 'i', 'git clean with force and directory deletion can remove untracked work.', 'always'],
26
+ ['recursive-delete', ['Bash'], '(?:^|[;&|\\n]\\s*)(?:(?:bash|sh)\\s+(?:-\\S+\\s+)*-c\\s+[\\"\']\\s*)?(?:sudo(?:\\s+-\\S+)*\\s+)?rm\\s+(?=[^\\r\\n;&|]*-(?:[^\\s]*r|r\\b))(?=[^\\r\\n;&|]*-(?:[^\\s]*f|f\\b))', 'i', 'Recursive forced deletion outside approved scratch space is destructive.', 'outside-scratch'],
27
+ ['powershell-recursive-force-delete', ['PowerShell'], '(?:^|[;&|\\n]\\s*)(?:Remove-Item|ri|rm)\\b(?=[^\\r\\n;|]*-(?:Recurse|r)\\b)(?=[^\\r\\n;|]*-(?:Force|fo)\\b)', 'i', 'PowerShell recursive forced deletion outside approved scratch space is destructive.', 'outside-scratch'],
28
+ ['sql-destructive-ddl', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)(?:(?:psql|mysql|sqlcmd|sqlite3)\\b[^\\r\\n;&|]*)?\\b(?:DROP\\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE(?:\\s+TABLE)?)\\b', 'i', 'Destructive SQL DDL can irreversibly remove production data or schema state.', 'always'],
29
+ ['package-publish', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)(?:sudo\\s+)?(?:npm|pnpm|yarn|cargo|twine)\\s+publish\\b', 'i', 'Publishing creates an external package/release side effect.', 'always'],
30
+ ['github-release-create', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)gh\\s+release\\s+create\\b', 'i', 'Creating a GitHub release is an external irreversible publication action.', 'always'],
31
+ ['production-deploy', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)(?:kubectl\\s+apply|helm\\s+(?:upgrade|install)|terraform\\s+apply|vercel\\s+deploy|fly\\s+deploy|gcloud\\s+run\\s+deploy|(?:npm|pnpm|yarn)\\s+(?:run\\s+)?deploy|deploy\\s+to\\s+(?:prod|production|live))\\b', 'i', 'Deployment changes an external environment and requires explicit approval.', 'always'],
32
32
  ].map(([id, tools, pattern, flags, reason, scope]) => ({ id, tools, pattern, flags, reason, scope }));
33
33
 
34
34
  function hashExact(value) {
@@ -153,9 +153,15 @@ function deleteTargets(command, ruleId) {
153
153
  const tails = [];
154
154
  let regex = null;
155
155
  if (ruleId === 'recursive-delete') {
156
- regex = /(?:^|[;&|\n]\s*)rm\s+([^;&|\n]+)/ig;
156
+ regex = /(?:^|[;&|\n]\s*)(?:sudo(?:\s+-\S+)*\s+)?rm\s+([^;&|\n]+)/ig;
157
+ const wrapper = /(?:^|[;&|\n]\s*)(?:bash|sh)\s+(?:-\S+\s+)*-c\s+(["'])([\s\S]*?)\1/ig;
158
+ let wrapped;
159
+ while ((wrapped = wrapper.exec(text)) !== null) {
160
+ tails.push(...deleteTargets(wrapped[2], ruleId));
161
+ if (wrapped[0].length === 0) wrapper.lastIndex++;
162
+ }
157
163
  } else if (ruleId === 'powershell-recursive-force-delete') {
158
- regex = /\bRemove-Item\b([^;|\n]+)/ig;
164
+ regex = /(?:^|[;|\n]\s*)(?:Remove-Item|ri|rm)\b([^;|\n]+)/ig;
159
165
  }
160
166
  if (!regex) return [];
161
167
 
@@ -190,6 +196,10 @@ function isWithin(target, root) {
190
196
 
191
197
  function scratchRoots(payload, sessionDir) {
192
198
  const roots = [os.tmpdir(), path.join(sessionDir, 'scratch')];
199
+ // Git Bash/MSYS exposes the Windows temp area as /tmp (and sometimes
200
+ // /var/tmp). Treat those aliases as scratch on Windows instead of gating a
201
+ // harmless temp cleanup merely because the host path syntax differs.
202
+ if (process.platform === 'win32') roots.push('/tmp', '/var/tmp');
193
203
  const declared = payload && (payload.scratchpad_path || payload.scratchpadPath || (payload.harness && payload.harness.scratchpadPath));
194
204
  if (declared) roots.push(declared);
195
205
  if (process.env.HARNESS_SCRATCH_DIR) roots.push(process.env.HARNESS_SCRATCH_DIR);
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Atomic Commit Check (PostToolUse: apply_patch)
4
- * PostToolUse can't block - the edit/write already happened. So this counts
5
- * apply_patch calls since the last commit and, once the count crosses a
6
- * threshold, feeds a strong reminder back to Claude (exit 2) to commit the
7
- * completed chunk before piling on more changes. Resets whenever HEAD moves.
3
+ * Atomic Commit Check (PostToolUse: Edit/Write/apply_patch)
4
+ * PostToolUse can't block - the edit/write already happened. Count only edits
5
+ * whose targets resolve inside the current workspace. Once the count crosses
6
+ * the threshold, emit a strong reminder only when the Git worktree actually
7
+ * has uncommitted changes. Resets whenever HEAD moves.
8
8
  * Fails open on any error - this is a nudge, never a hard stop.
9
9
  */
10
10
  const { execSync } = require('child_process');
@@ -15,6 +15,90 @@ const { getWorkspaceRoot, getSessionDir } = require('./lib/harness-state');
15
15
  const THRESHOLD = 6;
16
16
  const RENUDGE_EVERY = 3;
17
17
 
18
+ function toolNameOf(payload) {
19
+ return String((payload && (payload.tool_name || payload.toolName)) || '');
20
+ }
21
+
22
+ function toolInputOf(payload) {
23
+ return (payload && (payload.tool_input || payload.toolInput || payload.input)) || {};
24
+ }
25
+
26
+ function pathApiFor(value) {
27
+ return /^[A-Za-z]:[\\/]/.test(String(value || '')) ? path.win32 : path;
28
+ }
29
+
30
+ function canonicalPath(value) {
31
+ const text = String(value || '');
32
+ if (!text) return null;
33
+ const api = pathApiFor(text);
34
+ const resolved = api.resolve(text);
35
+
36
+ // realpath() is necessary on macOS where /var is a symlink to /private/var.
37
+ // New Write targets may not exist yet, so canonicalize the nearest existing
38
+ // ancestor and append the missing path segments without requiring creation.
39
+ const missing = [];
40
+ let cursor = resolved;
41
+ while (cursor && !fs.existsSync(cursor)) {
42
+ const parent = api.dirname(cursor);
43
+ if (!parent || parent === cursor) break;
44
+ missing.unshift(api.basename(cursor));
45
+ cursor = parent;
46
+ }
47
+ if (!cursor || !fs.existsSync(cursor)) return resolved;
48
+
49
+ let real = fs.realpathSync.native ? fs.realpathSync.native(cursor) : fs.realpathSync(cursor);
50
+ for (const segment of missing) real = api.join(real, segment);
51
+ return api.normalize(real);
52
+ }
53
+
54
+ function isWithin(target, root) {
55
+ if (!target || !root) return false;
56
+ const api = pathApiFor(target) === path.win32 || pathApiFor(root) === path.win32 ? path.win32 : path;
57
+ const resolvedTarget = canonicalPath(target);
58
+ const resolvedRoot = canonicalPath(root);
59
+ const relative = api.relative(resolvedRoot, resolvedTarget);
60
+ if (!relative) return true;
61
+ return relative !== '..' && !relative.startsWith(`..${api.sep}`) && !api.isAbsolute(relative);
62
+ }
63
+
64
+ function absoluteTarget(target, root) {
65
+ const text = String(target || '').trim();
66
+ if (!text) return null;
67
+ const api = pathApiFor(text) === path.win32 || pathApiFor(root) === path.win32 ? path.win32 : path;
68
+ return api.isAbsolute(text) ? api.normalize(text) : api.resolve(root, text);
69
+ }
70
+
71
+ function patchTargets(patchText) {
72
+ const targets = [];
73
+ const regex = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+?)\s*$/gm;
74
+ let match;
75
+ while ((match = regex.exec(String(patchText || ''))) !== null) targets.push(match[1]);
76
+ return targets;
77
+ }
78
+
79
+ function editTargets(payload) {
80
+ const tool = toolNameOf(payload);
81
+ const input = toolInputOf(payload);
82
+ if (tool === 'Edit' || tool === 'Write') {
83
+ const target = input.file_path || input.filePath || input.path;
84
+ return target ? [String(target)] : [];
85
+ }
86
+ if (tool === 'apply_patch') {
87
+ return patchTargets(input.patch || input.command || input.content || '');
88
+ }
89
+ return [];
90
+ }
91
+
92
+ function touchesWorkspace(payload, root) {
93
+ const targets = editTargets(payload);
94
+ if (targets.length === 0) return false;
95
+ return targets.some(target => isWithin(absoluteTarget(target, root), root));
96
+ }
97
+
98
+ function writeState(stateFile, state) {
99
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
100
+ }
101
+
18
102
  function main(payload) {
19
103
  try {
20
104
  const root = getWorkspaceRoot(payload);
@@ -33,17 +117,32 @@ function main(payload) {
33
117
  state.editCount = 0;
34
118
  }
35
119
 
120
+ // Host scratchpads, memory stores, temp files, and any other target outside
121
+ // the repository must not contribute to a repository commit reminder.
122
+ if (!touchesWorkspace(payload, root)) {
123
+ writeState(stateFile, state);
124
+ process.exit(0);
125
+ }
126
+
36
127
  state.editCount += 1;
37
- fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
128
+ writeState(stateFile, state);
38
129
 
39
130
  const overThreshold = state.editCount >= THRESHOLD;
40
131
  const onNudgeBeat = overThreshold && (state.editCount - THRESHOLD) % RENUDGE_EVERY === 0;
41
132
 
42
133
  if (onNudgeBeat) {
43
134
  const statusRaw = execSync('git status --porcelain', { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
44
- const changedFiles = statusRaw ? statusRaw.split('\n').length : 0;
45
- console.error(`[Atomic Commit Check] ${state.editCount} apply_patch calls since the last commit (${changedFiles} files currently changed).`);
46
- console.error(`If a logically complete chunk of work is done, commit it now before continuing - large uncommitted diffs are harder to review and harder to recover from.`);
135
+ if (!statusRaw) {
136
+ // Calls that produced no Git-visible change (or work already reverted)
137
+ // must not leave a stale counter that immediately re-nudges later.
138
+ state.editCount = 0;
139
+ writeState(stateFile, state);
140
+ process.exit(0);
141
+ }
142
+
143
+ const changedFiles = statusRaw.split('\n').length;
144
+ console.error(`[Atomic Commit Check] ${state.editCount} edits since the last commit (${changedFiles} files currently changed).`);
145
+ console.error('If a logically complete chunk of work is done, commit it now before continuing - large uncommitted diffs are harder to review and harder to recover from.');
47
146
  process.exit(2);
48
147
  }
49
148
 
@@ -66,3 +165,12 @@ process.stdin.on('error', () => {
66
165
  clearTimeout(timeout);
67
166
  main(null);
68
167
  });
168
+
169
+ module.exports = {
170
+ absoluteTarget,
171
+ canonicalPath,
172
+ editTargets,
173
+ isWithin,
174
+ patchTargets,
175
+ touchesWorkspace,
176
+ };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "harness-everything",
4
- "version": "0.5.3",
4
+ "version": "0.6.1",
5
5
  "description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
6
6
  "author": {
7
7
  "name": "Miya Daniel",