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.
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const assert = require('assert');
5
+ const { execFileSync, spawnSync } = require('child_process');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+
10
+ const ROOT = path.resolve(__dirname, '..');
11
+ const canonical = path.join(ROOT, 'hooks', 'scripts', 'atomic-commit-check.js');
12
+ const plugin = path.join(ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'atomic-commit-check.js');
13
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-atomic-commit-'));
14
+ const repo = path.join(temp, 'repo');
15
+ const outside = path.join(temp, 'outside');
16
+ const stateHome = path.join(temp, 'state');
17
+
18
+ function git(args) {
19
+ return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
20
+ }
21
+
22
+ function invoke({ session, tool = 'Edit', target, patch }) {
23
+ const toolInput = tool === 'apply_patch'
24
+ ? { patch }
25
+ : { file_path: target };
26
+ const payload = {
27
+ session_id: session,
28
+ hook_event_name: 'PostToolUse',
29
+ tool_name: tool,
30
+ tool_input: toolInput,
31
+ cwd: repo,
32
+ };
33
+ return spawnSync(process.execPath, [canonical], {
34
+ cwd: repo,
35
+ input: JSON.stringify(payload),
36
+ encoding: 'utf8',
37
+ env: { ...process.env, HARNESS_STATE_HOME: stateHome },
38
+ });
39
+ }
40
+
41
+ function sixInvocations(args) {
42
+ const results = [];
43
+ for (let i = 0; i < 6; i++) results.push(invoke(args));
44
+ return results;
45
+ }
46
+
47
+ try {
48
+ fs.mkdirSync(repo, { recursive: true });
49
+ fs.mkdirSync(outside, { recursive: true });
50
+ git(['init']);
51
+ git(['config', 'user.email', 'harness@example.invalid']);
52
+ git(['config', 'user.name', 'Harness Test']);
53
+ fs.writeFileSync(path.join(repo, 'tracked.txt'), 'base\n', 'utf8');
54
+ git(['add', 'tracked.txt']);
55
+ git(['commit', '-m', 'initial']);
56
+
57
+ assert.strictEqual(
58
+ fs.readFileSync(canonical, 'utf8'),
59
+ fs.readFileSync(plugin, 'utf8'),
60
+ 'canonical and packaged atomic-commit hooks must remain byte-identical',
61
+ );
62
+
63
+ // Negative control for #90: keep the repository dirty with a pre-existing,
64
+ // unrelated file. Six host edits outside the workspace must still never
65
+ // increment the repository edit counter or emit a commit nudge.
66
+ fs.writeFileSync(path.join(repo, 'pre-existing-unrelated.txt'), 'unrelated\n', 'utf8');
67
+ const outsideTarget = path.join(outside, 'scratch.txt');
68
+ const outsideResults = sixInvocations({ session: 'outside-edit', target: outsideTarget });
69
+ assert.ok(outsideResults.every(result => result.status === 0), 'outside-workspace Edit calls must never nudge even when the repo is already dirty');
70
+ assert.ok(outsideResults.every(result => !String(result.stderr || '').includes('Atomic Commit Check')), 'outside-workspace Edit calls must stay silent');
71
+
72
+ const outsidePatchResults = sixInvocations({
73
+ session: 'outside-patch',
74
+ tool: 'apply_patch',
75
+ patch: `*** Begin Patch\n*** Update File: ${outsideTarget}\n@@\n-old\n+new\n*** End Patch`,
76
+ });
77
+ assert.ok(outsidePatchResults.every(result => result.status === 0), 'outside-workspace apply_patch headers must not increment the count');
78
+ fs.rmSync(path.join(repo, 'pre-existing-unrelated.txt'));
79
+
80
+ // A clean worktree is never a valid reason to request a commit. The sixth
81
+ // in-workspace event therefore stays non-blocking and resets the stale count.
82
+ const cleanResults = sixInvocations({ session: 'clean-tree', target: path.join(repo, 'tracked.txt') });
83
+ assert.ok(cleanResults.every(result => result.status === 0), 'clean worktree must not nudge at the threshold');
84
+ assert.ok(cleanResults.every(result => !String(result.stderr || '').includes('Atomic Commit Check')), 'clean worktree must not print a commit reminder');
85
+
86
+ // Real in-workspace edits with Git-visible changes still preserve the nudge.
87
+ fs.writeFileSync(path.join(repo, 'tracked.txt'), 'changed\n', 'utf8');
88
+ const dirtyResults = sixInvocations({ session: 'dirty-tree', target: path.join(repo, 'tracked.txt') });
89
+ assert.ok(dirtyResults.slice(0, 5).every(result => result.status === 0), 'first five in-workspace edits remain non-blocking');
90
+ assert.strictEqual(dirtyResults[5].status, 2, 'sixth in-workspace edit with uncommitted changes nudges');
91
+ assert.match(String(dirtyResults[5].stderr || ''), /6 edits since the last commit/, 'nudge wording is tool-neutral');
92
+ assert.doesNotMatch(String(dirtyResults[5].stderr || ''), /apply_patch calls/, 'nudge no longer claims all edits are apply_patch calls');
93
+
94
+ // apply_patch paths inside the repository count too.
95
+ const patchResults = sixInvocations({
96
+ session: 'inside-patch',
97
+ tool: 'apply_patch',
98
+ patch: '*** Begin Patch\n*** Update File: tracked.txt\n@@\n-old\n+new\n*** End Patch',
99
+ });
100
+ assert.strictEqual(patchResults[5].status, 2, 'in-workspace apply_patch header participates in the threshold');
101
+
102
+ // Moving HEAD resets the counter for the same session.
103
+ git(['add', 'tracked.txt']);
104
+ git(['commit', '-m', 'save change']);
105
+ const afterCommit = invoke({ session: 'dirty-tree', target: path.join(repo, 'tracked.txt') });
106
+ assert.strictEqual(afterCommit.status, 0, 'counter resets after a commit moves HEAD');
107
+
108
+ console.log('PASS: atomic commit check scopes edits to the workspace and suppresses clean-tree false nudges');
109
+ } finally {
110
+ fs.rmSync(temp, { recursive: true, force: true });
111
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const assert = require('assert');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const ROOT = path.resolve(__dirname, '..');
10
+ const canonicalScript = path.join(ROOT, 'hooks', 'scripts', 'action-gate.js');
11
+ const pluginScript = path.join(ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'action-gate.js');
12
+ const canonicalRules = path.join(ROOT, 'hooks', 'scripts', 'action-gate-rules.json');
13
+ const pluginRules = path.join(ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'action-gate-rules.json');
14
+ const actionGate = require(canonicalScript);
15
+
16
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-action-gate-hardening-'));
17
+ const sessionDir = path.join(temp, 'session');
18
+ fs.mkdirSync(path.join(sessionDir, 'scratch'), { recursive: true });
19
+
20
+ function payload(command, tool = 'Bash', cwd = ROOT) {
21
+ return {
22
+ session_id: 'classifier-hardening',
23
+ hook_event_name: 'PreToolUse',
24
+ tool_name: tool,
25
+ tool_input: { command },
26
+ cwd,
27
+ };
28
+ }
29
+
30
+ function matched(table, command, expected, tool = 'Bash', cwd = ROOT) {
31
+ const rule = actionGate.classify(payload(command, tool, cwd), table.rules, sessionDir);
32
+ assert.ok(rule, `${command} should match ${expected}`);
33
+ assert.strictEqual(rule.id, expected, `${command} should match ${expected}, got ${rule && rule.id}`);
34
+ }
35
+
36
+ function allowed(table, command, tool = 'Bash', cwd = ROOT) {
37
+ const rule = actionGate.classify(payload(command, tool, cwd), table.rules, sessionDir);
38
+ assert.strictEqual(rule, null, `${command} should not be classified as destructive`);
39
+ }
40
+
41
+ try {
42
+ assert.strictEqual(fs.readFileSync(canonicalScript, 'utf8'), fs.readFileSync(pluginScript, 'utf8'), 'canonical and packaged action-gate scripts must remain byte-identical');
43
+ assert.strictEqual(fs.readFileSync(canonicalRules, 'utf8'), fs.readFileSync(pluginRules, 'utf8'), 'canonical and packaged action-gate rule tables must remain byte-identical');
44
+
45
+ const table = actionGate.loadRuleTable(canonicalRules);
46
+ assert.strictEqual(table.degraded, false, 'production rule table must load normally');
47
+
48
+ const builtinById = new Map(actionGate.BUILTIN_RULES.map(rule => [rule.id, rule]));
49
+ for (const rule of table.rules) {
50
+ const fallback = builtinById.get(rule.id);
51
+ assert.ok(fallback, `built-in fallback must contain ${rule.id}`);
52
+ assert.deepStrictEqual(
53
+ { tools: fallback.tools, pattern: fallback.pattern, flags: fallback.flags, reason: fallback.reason, scope: fallback.scope },
54
+ { tools: rule.tools, pattern: rule.pattern, flags: rule.flags, reason: rule.reason, scope: rule.scope },
55
+ `built-in fallback must stay aligned with configured rule ${rule.id}`,
56
+ );
57
+ }
58
+
59
+ // Confirmed fail-open variants from #100.
60
+ matched(table, 'git -C . push --force origin main', 'git-force-push');
61
+ matched(table, 'git push origin +main', 'git-force-push');
62
+ matched(table, 'git push -uf origin main', 'git-force-push');
63
+ matched(table, 'git reset -q --hard HEAD~1', 'git-reset-hard');
64
+ matched(table, 'git -c key=value reset --hard', 'git-reset-hard');
65
+ matched(table, 'sudo rm -rf /opt/app', 'recursive-delete');
66
+ matched(table, 'bash -c "rm -rf C:/important"', 'recursive-delete');
67
+ matched(table, 'ri -Recurse -Force C:\\repo\\x', 'powershell-recursive-force-delete', 'PowerShell');
68
+ matched(table, 'rm -r -fo C:\\repo\\x', 'powershell-recursive-force-delete', 'PowerShell');
69
+
70
+ // Quoted/descriptive text is not an executable destructive command.
71
+ allowed(table, 'node -e "console.log(\'Remove-Item -Recurse -Force C:\\\\repo\\\\x\')"', 'Bash');
72
+ allowed(table, 'echo "never DROP TABLE users"');
73
+ allowed(table, 'grep -rn "npm publish" docs/');
74
+ allowed(table, 'git push origin main');
75
+
76
+ // Scratch cleanup keeps working for direct commands and PowerShell aliases.
77
+ const bashScratch = process.platform === 'win32' ? '/tmp/harness-action-gate-safe' : path.join(os.tmpdir(), 'harness-action-gate-safe');
78
+ allowed(table, `rm -rf "${bashScratch}"`, 'Bash');
79
+ const psScratch = path.join(sessionDir, 'scratch', 'build');
80
+ allowed(table, `rm -r -fo "${psScratch}"`, 'PowerShell');
81
+ allowed(table, `ri -Recurse -Force "${psScratch}"`, 'PowerShell');
82
+
83
+ // An unresolved variable remains conservative rather than creating a new
84
+ // fail-open path: the gate cannot prove that the target is scratch space.
85
+ matched(table, 'rm -rf "$S"', 'recursive-delete');
86
+
87
+ console.log('PASS: destructive-command classifier covers confirmed bypasses while suppressing quoted-text false positives');
88
+ } finally {
89
+ fs.rmSync(temp, { recursive: true, force: true });
90
+ }
@@ -0,0 +1,138 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const helper = require('./test-helper');
4
+ const {
5
+ BLOCKED_MARKER,
6
+ HARDLOCK_MESSAGE,
7
+ INSTALLED_PLUGIN_NAME,
8
+ POST_REFLECTION_MARKER,
9
+ PRELOCK_MARKER,
10
+ verifyEvidence,
11
+ } = require('../behavioral-evals/opencode-hardlock-live');
12
+
13
+ console.log('\n[31] opencode live hard-lock evidence contract...');
14
+
15
+ function writeJson(file, value) {
16
+ fs.mkdirSync(path.dirname(file), { recursive: true });
17
+ fs.writeFileSync(file, JSON.stringify(value, null, 2));
18
+ }
19
+
20
+ function makeEvidence() {
21
+ const dir = helper.tempDir('.mechanism-test-opencode-live-evidence');
22
+ fs.mkdirSync(path.join(dir, 'workspace'), { recursive: true });
23
+ fs.mkdirSync(path.join(dir, 'state'), { recursive: true });
24
+ const pluginHash = 'a'.repeat(64);
25
+ writeJson(path.join(dir, 'metadata.json'), {
26
+ schemaVersion: 1,
27
+ stateIsolated: true,
28
+ installedPluginName: INSTALLED_PLUGIN_NAME,
29
+ pluginSha256: pluginHash,
30
+ installedPluginSha256: pluginHash,
31
+ });
32
+ const toolEvent = (input) => JSON.stringify({
33
+ type: 'tool',
34
+ part: { tool: 'edit', state: { input } },
35
+ });
36
+ fs.writeFileSync(path.join(dir, 'transcript.jsonl'), [
37
+ toolEvent({ filePath: 'probe.txt', text: PRELOCK_MARKER }),
38
+ toolEvent({ filePath: 'probe.txt', text: POST_REFLECTION_MARKER }),
39
+ toolEvent({ filePath: 'probe.txt', text: BLOCKED_MARKER }),
40
+ JSON.stringify({ type: 'text', part: { text: `${HARDLOCK_MESSAGE} after a repeat failure post-reflection.` } }),
41
+ '',
42
+ ].join('\n'));
43
+ fs.writeFileSync(path.join(dir, 'stderr.txt'), '', 'utf8');
44
+ fs.writeFileSync(path.join(dir, 'workspace', 'probe.txt'), `baseline\n${PRELOCK_MARKER}\n${POST_REFLECTION_MARKER}\n`, 'utf8');
45
+ writeJson(path.join(dir, 'state', 'circuit-breaker.json'), {
46
+ failures: {
47
+ 'npm test: HARNESS_LIVE_PROBE_RED': { count: 4, firstSeen: 1, lastSeen: 5 },
48
+ },
49
+ hardLock: true,
50
+ lastReflection: 4,
51
+ lastReflectionSignature: 'npm test: HARNESS_LIVE_PROBE_RED',
52
+ reflectionPending: false,
53
+ reflectionRequestedAt: null,
54
+ reflectionToken: null,
55
+ reflectionSignature: null,
56
+ });
57
+ fs.writeFileSync(path.join(dir, 'state', 'zoom-out-report.md'), [
58
+ '## Goal',
59
+ 'exercise the breaker',
60
+ '## Failed Attempts',
61
+ 'three controlled failures',
62
+ '## Verified Facts',
63
+ 'the verification fixture intentionally stays red',
64
+ '## Diagnosis',
65
+ 'this is a live enforcement probe',
66
+ '## Decision',
67
+ 'RESUME: reproduce the same failure once after reflection',
68
+ 'Reflection token: abc123',
69
+ '',
70
+ ].join('\n'));
71
+ return dir;
72
+ }
73
+
74
+ try {
75
+ const valid = makeEvidence();
76
+ let result = verifyEvidence(valid);
77
+ helper.check('31. complete attributed hard-lock evidence passes', result.passed, JSON.stringify(result.checks.filter((entry) => !entry.pass)));
78
+
79
+ const wrongPlugin = makeEvidence();
80
+ const metadataFile = path.join(wrongPlugin, 'metadata.json');
81
+ const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf8'));
82
+ metadata.installedPluginSha256 = 'b'.repeat(64);
83
+ writeJson(metadataFile, metadata);
84
+ result = verifyEvidence(wrongPlugin);
85
+ helper.check(
86
+ '31. evidence fails when the installed plugin hash differs from the canonical source hash',
87
+ !result.passed && result.checks.some((entry) => entry.name === 'installed plugin is byte-identical to the recorded canonical source' && !entry.pass),
88
+ JSON.stringify(result.checks),
89
+ );
90
+
91
+ const landed = makeEvidence();
92
+ fs.appendFileSync(path.join(landed, 'workspace', 'probe.txt'), `${BLOCKED_MARKER}\n`);
93
+ result = verifyEvidence(landed);
94
+ helper.check(
95
+ '31. negative control fails if the blocked marker reached the filesystem',
96
+ !result.passed && result.checks.some((entry) => entry.name === 'blocked marker did not reach the filesystem' && !entry.pass),
97
+ JSON.stringify(result.checks),
98
+ );
99
+
100
+ const unlocked = makeEvidence();
101
+ const breakerFile = path.join(unlocked, 'state', 'circuit-breaker.json');
102
+ const breaker = JSON.parse(fs.readFileSync(breakerFile, 'utf8'));
103
+ breaker.hardLock = false;
104
+ writeJson(breakerFile, breaker);
105
+ result = verifyEvidence(unlocked);
106
+ helper.check(
107
+ '31. evidence fails without a pre-reset hard-lock snapshot',
108
+ !result.passed && result.checks.some((entry) => entry.name === 'pre-reset breaker snapshot is hard-locked' && !entry.pass),
109
+ JSON.stringify(result.checks),
110
+ );
111
+
112
+ const noAttempt = makeEvidence();
113
+ const transcript = fs.readFileSync(path.join(noAttempt, 'transcript.jsonl'), 'utf8')
114
+ .split(/\r?\n/)
115
+ .filter((line) => !line.includes(BLOCKED_MARKER))
116
+ .join('\n');
117
+ fs.writeFileSync(path.join(noAttempt, 'transcript.jsonl'), transcript);
118
+ result = verifyEvidence(noAttempt);
119
+ helper.check(
120
+ '31. evidence fails when the final blocked edit was never observed as a structured tool attempt',
121
+ !result.passed && result.checks.some((entry) => entry.name === 'structured tool trace contains the final blocked edit attempt' && !entry.pass),
122
+ JSON.stringify(result.checks),
123
+ );
124
+
125
+ const noReflection = makeEvidence();
126
+ fs.rmSync(path.join(noReflection, 'state', 'zoom-out-report.md'));
127
+ result = verifyEvidence(noReflection);
128
+ helper.check(
129
+ '31. evidence fails without the preserved reflection artifact',
130
+ !result.passed && result.checks.some((entry) => entry.name === 'reflection artifact is valid and preserved' && !entry.pass),
131
+ JSON.stringify(result.checks),
132
+ );
133
+
134
+ helper.finish();
135
+ } catch (error) {
136
+ helper.check('31. OpenCode hard-lock live evidence contract', false, error.stack);
137
+ helper.finish();
138
+ }
@@ -0,0 +1,254 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const helper = require('./test-helper');
7
+ const {
8
+ mcnemarExactP,
9
+ prepareArmWorkspace,
10
+ summarizePairs,
11
+ validatePairRecord,
12
+ validateRunConfig,
13
+ } = require('../behavioral-evals/paired-benchmark');
14
+
15
+ console.log('\n[32] paired behavioral benchmark contract...');
16
+
17
+ function stable(value) {
18
+ if (Array.isArray(value)) return value.map(stable);
19
+ if (!value || typeof value !== 'object') return value;
20
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
21
+ }
22
+
23
+ function hash(value) {
24
+ return crypto.createHash('sha256').update(JSON.stringify(stable(value))).digest('hex');
25
+ }
26
+
27
+ function skillContract(caseId = 'case-a') {
28
+ return {
29
+ schema_version: 1,
30
+ effect_type: 'skill-text',
31
+ engine: 'opencode',
32
+ requested_model: 'test/model',
33
+ case_id: caseId,
34
+ max_turns: 8,
35
+ loaded_skills: ['tdd'],
36
+ fixture_sha256: 'fixture-hash',
37
+ prompt_sha256: 'prompt-hash',
38
+ rubric_sha256: 'rubric-hash',
39
+ execution_policy: 'opencode-auto',
40
+ };
41
+ }
42
+
43
+ function arm(outcome, treatment, overrides = {}) {
44
+ return {
45
+ outcome,
46
+ model_name: 'test/model',
47
+ intervention: {
48
+ skill_text: treatment,
49
+ plugin_enforcement: false,
50
+ },
51
+ loaded_skills: treatment ? ['tdd'] : [],
52
+ plugin_sha256: null,
53
+ plugin_attribution: null,
54
+ cost: treatment ? 2 : 1,
55
+ total_tokens: treatment ? 120 : 100,
56
+ tool_call_count: treatment ? 4 : 3,
57
+ duration_ms: treatment ? 1200 : 1000,
58
+ ...overrides,
59
+ };
60
+ }
61
+
62
+ function skillPair(id, baselineOutcome, treatmentOutcome, overrides = {}) {
63
+ const contract = skillContract(overrides.caseId || id);
64
+ return {
65
+ schema_version: 1,
66
+ id: overrides.caseId || id,
67
+ pair_id: id,
68
+ effect_type: 'skill-text',
69
+ contract,
70
+ contract_sha256: hash(contract),
71
+ arms: {
72
+ baseline: arm(baselineOutcome, false, overrides.baseline || {}),
73
+ treatment: arm(treatmentOutcome, true, overrides.treatment || {}),
74
+ },
75
+ };
76
+ }
77
+
78
+ try {
79
+ const missingThreshold = validateRunConfig({
80
+ effect_type: 'skill-text', engine: 'opencode', model: 'test/model',
81
+ min_effect_pp: NaN, repeats: 1,
82
+ });
83
+ helper.check(
84
+ '32. a paired study cannot start without a predeclared minimum effect',
85
+ missingThreshold.some((problem) => problem.includes('min-effect-pp')),
86
+ missingThreshold.join('; '),
87
+ );
88
+
89
+ const noModel = validateRunConfig({
90
+ effect_type: 'skill-text', engine: 'opencode', model: '',
91
+ min_effect_pp: 10, repeats: 1,
92
+ });
93
+ helper.check(
94
+ '32. an explicit model is required so both arms share the same model contract',
95
+ noModel.some((problem) => problem.includes('explicit --model')),
96
+ noModel.join('; '),
97
+ );
98
+
99
+ const badPluginEngine = validateRunConfig({
100
+ effect_type: 'plugin-enforcement', engine: 'claude', model: 'test/model',
101
+ min_effect_pp: 10, repeats: 1,
102
+ });
103
+ helper.check(
104
+ '32. plugin-enforcement is not generalized beyond the currently attributed OpenCode surface',
105
+ badPluginEngine.some((problem) => problem.includes('opencode only')),
106
+ badPluginEngine.join('; '),
107
+ );
108
+
109
+ const fixtureCase = {
110
+ id: 'workspace-shape',
111
+ discipline: 'tdd',
112
+ fixture: { files: [{ path: 'probe.txt', content: 'baseline\n' }] },
113
+ };
114
+ const skillBaseline = prepareArmWorkspace(fixtureCase, 'skill-text', 'baseline', 'opencode');
115
+ const skillTreatment = prepareArmWorkspace(fixtureCase, 'skill-text', 'treatment', 'opencode');
116
+ helper.check(
117
+ '32. skill-text control contains no Harness skill or plugin',
118
+ !fs.existsSync(path.join(skillBaseline.ws, '.claude', 'skills', 'tdd', 'SKILL.md')) &&
119
+ !fs.existsSync(path.join(skillBaseline.ws, '.opencode', 'plugins', 'harness-enforcement.js')),
120
+ skillBaseline.ws,
121
+ );
122
+ helper.check(
123
+ '32. skill-text treatment installs the named skill without installing hooks/plugin enforcement',
124
+ fs.existsSync(path.join(skillTreatment.ws, '.claude', 'skills', 'tdd', 'SKILL.md')) &&
125
+ !fs.existsSync(path.join(skillTreatment.ws, '.claude', 'settings.json')) &&
126
+ !fs.existsSync(path.join(skillTreatment.ws, '.opencode', 'plugins', 'harness-enforcement.js')),
127
+ skillTreatment.ws,
128
+ );
129
+ fs.rmSync(skillBaseline.ws, { recursive: true, force: true });
130
+ fs.rmSync(skillTreatment.ws, { recursive: true, force: true });
131
+
132
+ const pluginBaseline = prepareArmWorkspace(fixtureCase, 'plugin-enforcement', 'baseline', 'opencode');
133
+ const pluginTreatment = prepareArmWorkspace(fixtureCase, 'plugin-enforcement', 'treatment', 'opencode');
134
+ helper.check(
135
+ '32. plugin experiment holds skill text constant and changes only enforcement installation',
136
+ fs.existsSync(path.join(pluginBaseline.ws, '.claude', 'skills', 'tdd', 'SKILL.md')) &&
137
+ fs.existsSync(path.join(pluginTreatment.ws, '.claude', 'skills', 'tdd', 'SKILL.md')) &&
138
+ !fs.existsSync(path.join(pluginBaseline.ws, '.opencode', 'plugins', 'harness-enforcement.js')) &&
139
+ fs.existsSync(path.join(pluginTreatment.ws, '.opencode', 'plugins', 'harness-enforcement.js')),
140
+ JSON.stringify({ baseline: pluginBaseline.ws, treatment: pluginTreatment.ws }),
141
+ );
142
+ fs.rmSync(pluginBaseline.ws, { recursive: true, force: true });
143
+ fs.rmSync(pluginTreatment.ws, { recursive: true, force: true });
144
+
145
+ const validSkillPair = skillPair('p1', 'fail', 'pass');
146
+ helper.check('32. a correctly isolated skill-text pair is includable', validatePairRecord(validSkillPair).included, JSON.stringify(validatePairRecord(validSkillPair)));
147
+
148
+ const modelMismatch = skillPair('model-mismatch', 'fail', 'pass', {
149
+ treatment: { model_name: 'other/model' },
150
+ });
151
+ helper.check(
152
+ '32. actual model mismatch excludes the pair instead of counting it as behavior',
153
+ !validatePairRecord(modelMismatch).included && validatePairRecord(modelMismatch).reason === 'actual-model-mismatch',
154
+ JSON.stringify(validatePairRecord(modelMismatch)),
155
+ );
156
+
157
+ const pluginContract = {
158
+ schema_version: 1,
159
+ effect_type: 'plugin-enforcement',
160
+ engine: 'opencode',
161
+ requested_model: 'test/model',
162
+ case_id: 'plugin-case',
163
+ max_turns: 8,
164
+ loaded_skills: ['tdd'],
165
+ fixture_sha256: 'fixture-hash',
166
+ prompt_sha256: 'prompt-hash',
167
+ rubric_sha256: 'rubric-hash',
168
+ execution_policy: 'opencode-auto',
169
+ };
170
+ const pluginPair = {
171
+ id: 'plugin-case',
172
+ pair_id: 'plugin-pair',
173
+ effect_type: 'plugin-enforcement',
174
+ contract: pluginContract,
175
+ contract_sha256: hash(pluginContract),
176
+ preflight: { passed: true, plugin_sha256: 'plugin-hash' },
177
+ arms: {
178
+ baseline: {
179
+ ...arm('fail', true),
180
+ intervention: { skill_text: true, plugin_enforcement: false },
181
+ loaded_skills: ['tdd'],
182
+ },
183
+ treatment: {
184
+ ...arm('pass', true),
185
+ intervention: { skill_text: true, plugin_enforcement: true },
186
+ loaded_skills: ['tdd'],
187
+ plugin_sha256: 'plugin-hash',
188
+ plugin_attribution: { fired: true, state_files: ['workspaces/x/state/sessions/y/edit-state.json'] },
189
+ },
190
+ },
191
+ };
192
+ helper.check(
193
+ '32. plugin pair is includable only when preflight, hash, constant skill text, and per-arm attribution agree',
194
+ validatePairRecord(pluginPair).included,
195
+ JSON.stringify(validatePairRecord(pluginPair)),
196
+ );
197
+ const unattributedPlugin = JSON.parse(JSON.stringify(pluginPair));
198
+ unattributedPlugin.pair_id = 'plugin-unattributed';
199
+ unattributedPlugin.arms.treatment.plugin_attribution.fired = false;
200
+ helper.check(
201
+ '32. missing live plugin attribution is infrastructure/inconclusive, not a behavioral result',
202
+ !validatePairRecord(unattributedPlugin).included && validatePairRecord(unattributedPlugin).reason === 'plugin-attribution-missing',
203
+ JSON.stringify(validatePairRecord(unattributedPlugin)),
204
+ );
205
+
206
+ const records = [
207
+ skillPair('a-r1', 'fail', 'pass', { caseId: 'a' }),
208
+ skillPair('a-r2', 'fail', 'pass', { caseId: 'a' }),
209
+ skillPair('b-r1', 'pass', 'fail', { caseId: 'b' }),
210
+ skillPair('c-r1', 'pass', 'pass', { caseId: 'c' }),
211
+ skillPair('infra-r1', 'pass', 'session-error', { caseId: 'infra' }),
212
+ ];
213
+ const summary = summarizePairs(records, 10);
214
+ helper.check(
215
+ '32. summary excludes infrastructure outcomes and reports paired transitions',
216
+ summary.requested_pairs === 5 && summary.included_pairs === 4 && summary.excluded_pairs === 1 &&
217
+ summary.transitions['fail->pass'] === 2 && summary.transitions['pass->fail'] === 1 &&
218
+ summary.transitions['pass->pass'] === 1 && summary.transitions['fail->fail'] === 0,
219
+ JSON.stringify(summary),
220
+ );
221
+ helper.check(
222
+ '32. paired effect is derived from within-pair transitions and carries a deterministic uncertainty interval',
223
+ summary.paired_effect_pp === 25 && Array.isArray(summary.paired_effect_ci95_pp) && summary.paired_effect_ci95_pp.length === 2,
224
+ JSON.stringify(summary),
225
+ );
226
+ helper.check(
227
+ '32. repeated runs remain separate samples and expose per-case variance instead of overwriting',
228
+ summary.repeat_variance.a.repeats === 2 && summary.repeat_variance.a.paired_difference_variance === 0,
229
+ JSON.stringify(summary.repeat_variance),
230
+ );
231
+ helper.check(
232
+ '32. cost and execution overhead are reported separately from correctness',
233
+ summary.cost_and_execution_overhead.cost_delta.mean === 1 &&
234
+ summary.cost_and_execution_overhead.token_delta.mean === 20 &&
235
+ summary.cost_and_execution_overhead.tool_call_delta.mean === 1 &&
236
+ summary.cost_and_execution_overhead.duration_ms_delta.mean === 200,
237
+ JSON.stringify(summary.cost_and_execution_overhead),
238
+ );
239
+ helper.check(
240
+ '32. exact discordant-pair statistic is available without turning it into an unpaired pass-rate verdict',
241
+ mcnemarExactP(2, 1) === 1 && summary.mcnemar_exact_p === 1,
242
+ JSON.stringify({ direct: mcnemarExactP(2, 1), summary: summary.mcnemar_exact_p }),
243
+ );
244
+ helper.check(
245
+ '32. the predeclared practical threshold is retained in the summary decision contract',
246
+ summary.decision.min_effect_pp === 10 && /predeclared/i.test(summary.decision.rule),
247
+ JSON.stringify(summary.decision),
248
+ );
249
+
250
+ helper.finish();
251
+ } catch (error) {
252
+ helper.check('32. paired behavioral benchmark contract', false, error.stack);
253
+ helper.finish();
254
+ }
@@ -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"