harness-everything 0.16.0 → 0.16.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +8 -0
- package/README.md +7 -7
- package/VERIFICATION.md +3 -3
- package/ci/invariant-routing.test.js +58 -158
- package/ci/mechanism-2a-rule-of-3.test.js +73 -138
- package/ci/mechanism-2ac-atomic-commit-check.test.js +2 -2
- package/ci/mechanism-2af-worktree-isolation.test.js +38 -196
- package/ci/mechanism-2ah-workflow-lifecycle.test.js +20 -831
- package/ci/mechanism-2ai-workspace-fingerprint-batch.test.js +3 -4
- package/ci/mechanism-2b-boundary-guard.test.js +6 -16
- package/ci/mechanism-2e-scope-guard.test.js +8 -24
- package/ci/mechanism-2f-stop-gate.test.js +21 -75
- package/ci/mechanism-2n-opencode-plugin.test.js +13 -28
- package/ci/openai-plugin-package.test.js +8 -8
- package/ci/openai-plugin-runtime.test.js +2 -2
- package/ci/router-workflow-plan.test.js +6 -6
- package/ci/self-evolve-loop.test.js +4 -1
- package/ci/workflow-execution-contract.test.js +51 -129
- package/ci/workflow-plan-consumers-edge.test.js +1 -1
- package/ci/workflow-plan-consumers.test.js +6 -6
- package/docs/architecture.md +21 -21
- package/docs/enforcement-locks.md +41 -102
- package/docs/mechanism-first-skill-mesh.md +2 -2
- package/docs/philosophy.md +2 -2
- package/docs/platform-capabilities.md +3 -3
- package/docs/routing.md +14 -14
- package/docs/workflow-runtime.md +80 -110
- package/evals/harness-everything/tasks/positive-tier1.yaml +1 -1
- package/evals/harness-everything/tasks/positive-tier2.yaml +1 -1
- package/evals/harness-everything/tasks/positive-tier3.yaml +1 -1
- package/evals/using-git-worktrees/tasks/positive-trigger.yaml +1 -1
- package/fable-mode/SKILL.md +1 -1
- package/fable-mode/execution-guardrails/SKILL.md +1 -1
- package/fable-mode/fable-haiku/SKILL.md +1 -1
- package/fable-mode/fable-opus/SKILL.md +1 -1
- package/fable-mode/fable-sonnet/SKILL.md +1 -1
- package/fable-mode/scripts/workflow-plan-consumer.js +4 -3
- package/harness-everything/SKILL.md +11 -11
- package/harness-everything/references/triage-and-tiers.md +11 -11
- package/harness-everything/scripts/kernel-router-core.js +5 -5
- package/harness-everything/scripts/kernel-router.js +15 -17
- package/harness-everything/scripts/router-contract-core.js +1 -1
- package/hooks/hooks.json +10 -23
- package/hooks/scripts/atomic-commit-check.js +1 -1
- package/hooks/scripts/boundary-guard.js +4 -4
- package/hooks/scripts/contract-test.js +8 -11
- package/hooks/scripts/depth-guard.js +2 -2
- package/hooks/scripts/lib/workflow-runtime.js +24 -402
- package/hooks/scripts/rule-of-3-tracker.js +28 -31
- package/hooks/scripts/rule-of-3.js +4 -28
- package/hooks/scripts/state-persist.js +36 -136
- package/hooks/scripts/stop-gate.js +15 -48
- package/hooks/scripts/subagent-scope-guard.js +8 -8
- package/hooks/scripts/workflow-disposition.js +2 -10
- package/hooks/scripts/workflow-gate.js +33 -84
- package/hooks/scripts/workflow-mutation-denied.js +3 -15
- package/hooks/scripts/workflow-stop-gate.js +23 -38
- package/opencode-plugin/README.md +9 -10
- package/opencode-plugin/index.mjs +14 -30
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/hooks/hooks.json +3 -3
- package/plugins/harness-everything/hooks/scripts/atomic-commit-check.js +1 -1
- package/plugins/harness-everything/hooks/scripts/boundary-guard.js +4 -4
- package/plugins/harness-everything/hooks/scripts/contract-test.js +8 -11
- package/plugins/harness-everything/hooks/scripts/depth-guard.js +2 -2
- package/plugins/harness-everything/hooks/scripts/lib/workflow-runtime.js +24 -402
- package/plugins/harness-everything/hooks/scripts/rule-of-3-tracker.js +28 -31
- package/plugins/harness-everything/hooks/scripts/rule-of-3.js +4 -28
- package/plugins/harness-everything/hooks/scripts/state-persist.js +36 -136
- package/plugins/harness-everything/hooks/scripts/stop-gate.js +15 -48
- package/plugins/harness-everything/hooks/scripts/subagent-scope-guard.js +8 -8
- package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +2 -10
- package/plugins/harness-everything/hooks/scripts/workflow-gate.js +33 -84
- package/plugins/harness-everything/hooks/scripts/workflow-mutation-denied.js +3 -15
- package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +23 -38
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/scripts/lib/advisory-text.js +16 -29
- package/plugins/harness-everything/skills/fable-mode/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/execution-guardrails/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/fable-haiku/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/fable-opus/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/fable-sonnet/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/scripts/workflow-plan-consumer.js +4 -3
- package/plugins/harness-everything/skills/harness-everything/SKILL.md +11 -11
- package/plugins/harness-everything/skills/harness-everything/references/triage-and-tiers.md +11 -11
- package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router-core.js +5 -5
- package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router.js +15 -17
- package/plugins/harness-everything/skills/harness-everything/scripts/router-contract-core.js +1 -1
- package/plugins/harness-everything/skills/using-git-worktrees/SKILL.md +5 -5
- package/scripts/lib/advisory-text.js +16 -29
- package/using-git-worktrees/SKILL.md +5 -5
|
@@ -1,833 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
const fs
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
const {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
fs.
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const sessionDir = state.getSessionDir(repo, sessionId);
|
|
24
|
-
const file = path.join(sessionDir, 'workflow-run.json');
|
|
25
|
-
const read = target => JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
26
|
-
const write = (target, value) => fs.writeFileSync(target, JSON.stringify(value));
|
|
27
|
-
let passed = 0;
|
|
28
|
-
function check(value, message) { assert.ok(value, message); passed++; console.log('PASS ' + message); }
|
|
29
|
-
function node(script, input, args = []) {
|
|
30
|
-
const relative = plugin && script.startsWith('harness-everything/') ? 'skills/' + script : script;
|
|
31
|
-
return spawnSync(process.execPath, [path.join(runtimeRoot, relative), ...args], { cwd: repo, env, input: input && JSON.stringify(input), encoding: 'utf8' });
|
|
32
|
-
}
|
|
33
|
-
function git(args) {
|
|
34
|
-
const result = spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
|
|
35
|
-
assert.strictEqual(result.status, 0, result.stderr);
|
|
36
|
-
}
|
|
37
|
-
const route = prompt => node('harness-everything/scripts/kernel-router.js', { ...payload, prompt });
|
|
38
|
-
const gate = (tool, input, cwd = linked) => node('hooks/scripts/workflow-gate.js', { ...payload, cwd, tool_name: tool, tool_input: input });
|
|
39
|
-
const persistDirect = (tool, input, cwd = repo, response = {}) => node('hooks/scripts/state-persist.js', {
|
|
40
|
-
...payload,
|
|
41
|
-
cwd,
|
|
42
|
-
hook_event_name: 'PostToolUse',
|
|
43
|
-
tool_name: tool,
|
|
44
|
-
tool_input: input,
|
|
45
|
-
tool_response: response,
|
|
46
|
-
});
|
|
47
|
-
let toolUseSeq = 0;
|
|
48
|
-
function beginShell(command, cwd = repo, extraToolInput = {}) {
|
|
49
|
-
const toolUseId = 'toolu_fixture_' + (++toolUseSeq);
|
|
50
|
-
const input = {
|
|
51
|
-
...payload,
|
|
52
|
-
cwd,
|
|
53
|
-
tool_name: 'Bash',
|
|
54
|
-
tool_use_id: toolUseId,
|
|
55
|
-
tool_input: { command, ...extraToolInput },
|
|
56
|
-
};
|
|
57
|
-
return { command, cwd, toolUseId, result: node('hooks/scripts/workflow-gate.js', input) };
|
|
58
|
-
}
|
|
59
|
-
function persistShell(call, response = {}, hookEvent = 'PostToolUse') {
|
|
60
|
-
const input = {
|
|
61
|
-
...payload,
|
|
62
|
-
cwd: call.cwd,
|
|
63
|
-
hook_event_name: hookEvent,
|
|
64
|
-
tool_name: 'Bash',
|
|
65
|
-
tool_use_id: call.toolUseId,
|
|
66
|
-
tool_input: { command: call.command },
|
|
67
|
-
};
|
|
68
|
-
if (hookEvent === 'PostToolUseFailure') {
|
|
69
|
-
input.error = response.error || 'Exit code 1\nfixture failure';
|
|
70
|
-
input.is_interrupt = Boolean(response.is_interrupt);
|
|
71
|
-
} else {
|
|
72
|
-
input.tool_response = response;
|
|
73
|
-
}
|
|
74
|
-
return node('hooks/scripts/state-persist.js', input);
|
|
75
|
-
}
|
|
76
|
-
function denyShell(call) {
|
|
77
|
-
return node('hooks/scripts/workflow-mutation-denied.js', {
|
|
78
|
-
...payload,
|
|
79
|
-
cwd: call.cwd,
|
|
80
|
-
hook_event_name: 'PermissionDenied',
|
|
81
|
-
permission_mode: 'auto',
|
|
82
|
-
tool_name: 'Bash',
|
|
83
|
-
tool_use_id: call.toolUseId,
|
|
84
|
-
tool_input: { command: call.command },
|
|
85
|
-
reason: 'Blocked by classifier',
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
const probeDir = path.join(sessionDir, 'mutation-probes');
|
|
89
|
-
const probeFiles = () => {
|
|
90
|
-
try { return fs.readdirSync(probeDir).filter(name => /^[a-f0-9]{64}\.json$/.test(name)); }
|
|
91
|
-
catch (_) { return []; }
|
|
92
|
-
};
|
|
93
|
-
function ageProbeFiles(ms = 60000) {
|
|
94
|
-
for (const name of probeFiles()) {
|
|
95
|
-
const target = path.join(probeDir, name);
|
|
96
|
-
const probe = read(target);
|
|
97
|
-
write(target, { ...probe, observedAt: Date.now() - ms, reclaimAfterAt: Date.now() - 1 });
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const stop = extra => node('hooks/scripts/workflow-stop-gate.js', { ...payload, ...extra });
|
|
101
|
-
const control = (...args) => node('hooks/scripts/workflow-disposition.js', null, [args[0], '--session-id', sessionId, ...args.slice(1)]);
|
|
102
|
-
function fixtureSession(fixtureSessionId) {
|
|
103
|
-
const fixturePayload = { session_id: fixtureSessionId, cwd: repo };
|
|
104
|
-
const fixtureSessionDir = state.getSessionDir(repo, fixtureSessionId);
|
|
105
|
-
const fixtureFile = path.join(fixtureSessionDir, 'workflow-run.json');
|
|
106
|
-
return {
|
|
107
|
-
payload: fixturePayload,
|
|
108
|
-
file: fixtureFile,
|
|
109
|
-
sessionDir: fixtureSessionDir,
|
|
110
|
-
route: prompt => node('harness-everything/scripts/kernel-router.js', { ...fixturePayload, prompt }),
|
|
111
|
-
gate: (tool, input, extra = {}) => node('hooks/scripts/workflow-gate.js', { ...fixturePayload, ...extra, tool_name: tool, tool_input: input }),
|
|
112
|
-
persist: (tool, input, response = {}, extra = {}) => node('hooks/scripts/state-persist.js', {
|
|
113
|
-
...fixturePayload,
|
|
114
|
-
...extra,
|
|
115
|
-
hook_event_name: 'PostToolUse',
|
|
116
|
-
tool_name: tool,
|
|
117
|
-
tool_input: input,
|
|
118
|
-
tool_response: response,
|
|
119
|
-
}),
|
|
120
|
-
stop: extra => node('hooks/scripts/workflow-stop-gate.js', { ...fixturePayload, ...extra }),
|
|
121
|
-
control: (...args) => node('hooks/scripts/workflow-disposition.js', null, [args[0], '--session-id', fixtureSessionId, ...args.slice(1)]),
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
const stages = [
|
|
125
|
-
{ stageId: 'build', goal: 'fix', agent: 'fable-worker', task: 'fix bounded module', dependsOn: [], writeSet: ['src'], checkCommand: 'node test-build.js', passCondition: 'exit 0' },
|
|
126
|
-
{ stageId: 'optional', goal: 'external scope', agent: 'fable-worker', task: 'external task', dependsOn: [], writeSet: [], checkCommand: 'node external.js', passCondition: 'exit 0' },
|
|
127
|
-
{ stageId: 'verify', goal: 'verify', agent: 'fable-verifier', task: 'independent review', dependsOn: ['build', 'optional'], writeSet: [], checkCommand: 'node verify.js', passCondition: 'exit 0' },
|
|
128
|
-
];
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
// #162: Tier-2 shell accounting must degrade conservatively when a workspace
|
|
132
|
-
// is not a Git repository. Lack of a Git fingerprint is not an isolation
|
|
133
|
-
// violation for a non-major workflow.
|
|
134
|
-
const nonGit = fixtureSession('workflow-non-git');
|
|
135
|
-
check(nonGit.route('Fix this checkout bug with a regression test').status === 0 &&
|
|
136
|
-
read(nonGit.file).strategy === 'iterative-single',
|
|
137
|
-
'#162 non-git fixture routes to Tier 2 iterative workflow');
|
|
138
|
-
check(nonGit.control('start').status === 0 && read(nonGit.file).state === 'running',
|
|
139
|
-
'#162 non-git Tier 2 workflow starts normally');
|
|
140
|
-
let nonGitIterations = read(nonGit.file).budget?.counters?.iterations || 0;
|
|
141
|
-
for (const [index, command] of ['npm --version', 'node -e "process.exit(0)"', 'cd .', 'git init -q'].entries()) {
|
|
142
|
-
const toolUseId = 'toolu_non_git_pre_' + index;
|
|
143
|
-
const admitted = nonGit.gate('Bash', { command }, { tool_use_id: toolUseId });
|
|
144
|
-
check(admitted.status === 0, '#162 non-git Tier 2 admits untrusted shell: ' + command);
|
|
145
|
-
const afterGate = read(nonGit.file);
|
|
146
|
-
check(afterGate.budget.counters.iterations === ++nonGitIterations &&
|
|
147
|
-
afterGate.lastMutationAt > 0 &&
|
|
148
|
-
afterGate.budget.events.at(-1)?.evidence === 'shell:unobservable-workspace',
|
|
149
|
-
'#162 unobservable non-git shell is conservatively counted once: ' + command);
|
|
150
|
-
check(nonGit.persist('Bash', { command }, { stdout: 'fixture success' }, { tool_use_id: toolUseId }).status === 0,
|
|
151
|
-
'#162 non-git shell PostToolUse stays fail-open without a probe: ' + command);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// A probe may have been created while Git was observable and become
|
|
155
|
-
// unobservable before PostToolUse (for example .git disappears). Tier 2
|
|
156
|
-
// must settle it conservatively instead of blocking the workflow.
|
|
157
|
-
const lostObservationId = 'toolu_non_git_post_fallback';
|
|
158
|
-
const lostObservationPayload = {
|
|
159
|
-
...nonGit.payload,
|
|
160
|
-
tool_name: 'Bash',
|
|
161
|
-
tool_use_id: lostObservationId,
|
|
162
|
-
tool_input: { command: 'node lost-observation.js' },
|
|
163
|
-
};
|
|
164
|
-
const lostObservationContext = workflowRuntime.loadWorkflow(lostObservationPayload);
|
|
165
|
-
const lostObservationKey = workflowIsolation.shellProbeKey(lostObservationPayload, repo);
|
|
166
|
-
workflowRuntime.registerMutationProbe(lostObservationContext, lostObservationKey, 'f'.repeat(64), true);
|
|
167
|
-
const beforeLostObservation = read(nonGit.file);
|
|
168
|
-
check(nonGit.persist('Bash', { command: 'node lost-observation.js' }, { stdout: 'fixture success' },
|
|
169
|
-
{ tool_use_id: lostObservationId }).status === 0,
|
|
170
|
-
'#162 Tier 2 PostToolUse degrades when workspace observation becomes unavailable');
|
|
171
|
-
const afterLostObservation = read(nonGit.file);
|
|
172
|
-
check(afterLostObservation.state === 'running' &&
|
|
173
|
-
afterLostObservation.budget.counters.iterations === beforeLostObservation.budget.counters.iterations + 1 &&
|
|
174
|
-
afterLostObservation.lastMutationAt >= (beforeLostObservation.lastMutationAt || 0),
|
|
175
|
-
'#162 lost post-observation is settled as one conservative mutation without blocking');
|
|
176
|
-
|
|
177
|
-
const nonGitTarget = path.join(repo, 'non-git-edit.txt');
|
|
178
|
-
check(nonGit.gate('Write', { file_path: nonGitTarget }).status === 0,
|
|
179
|
-
'#162 direct edit remains available in non-git Tier 2');
|
|
180
|
-
fs.writeFileSync(nonGitTarget, 'changed\n');
|
|
181
|
-
check(nonGit.persist('Write', { file_path: nonGitTarget }).status === 0,
|
|
182
|
-
'#162 direct non-git edit persists mutation milestone');
|
|
183
|
-
const verifyId = 'toolu_non_git_verify';
|
|
184
|
-
check(nonGit.gate('Bash', { command: 'node run-test.js' }, { tool_use_id: verifyId }).status === 0,
|
|
185
|
-
'#162 verification shell is admitted in non-git Tier 2');
|
|
186
|
-
check(nonGit.persist('Bash', { command: 'node run-test.js' }, { stdout: 'tests passed' },
|
|
187
|
-
{ tool_use_id: verifyId }).status === 0,
|
|
188
|
-
'#162 non-git verification result is persisted');
|
|
189
|
-
check(nonGit.stop().status === 0 && read(nonGit.file).state === 'satisfied',
|
|
190
|
-
'#162 edit then verification can satisfy Stop in non-git Tier 2');
|
|
191
|
-
|
|
192
|
-
const nonGitMajor = fixtureSession('workflow-non-git-major');
|
|
193
|
-
check(nonGitMajor.route('Refactor the entire authentication architecture across all services without fable').status === 0 &&
|
|
194
|
-
read(nonGitMajor.file).tier === 'tier3',
|
|
195
|
-
'#162 non-git major fixture routes to Tier 3');
|
|
196
|
-
check(nonGitMajor.control('start').status === 0 && read(nonGitMajor.file).state === 'running',
|
|
197
|
-
'#162 non-Fable Tier 3 controller can enter running state before mutation');
|
|
198
|
-
const majorBlocked = nonGitMajor.gate('Bash', { command: 'node mutate.js' }, { tool_use_id: 'toolu_non_git_major' });
|
|
199
|
-
check(majorBlocked.status === 2 && /Git worktree isolation/i.test(majorBlocked.stderr),
|
|
200
|
-
'#162 non-git Tier 3 remains fail-closed with an isolation-specific error');
|
|
201
|
-
|
|
202
|
-
fs.unlinkSync(nonGitTarget);
|
|
203
|
-
git(['init']); fs.writeFileSync(path.join(repo, 'README.md'), 'fixture\n'); git(['add', '.']);
|
|
204
|
-
git(['-c', 'user.name=Harness Test', '-c', 'user.email=harness@example.invalid', 'commit', '-m', 'fixture']);
|
|
205
|
-
git(['worktree', 'add', linked, '-b', 'isolated']);
|
|
206
|
-
|
|
207
|
-
// #164: the mutation budget is an edit budget, not a reason to make the
|
|
208
|
-
// final edit unverifiable. At the exact limit a verification-shaped shell
|
|
209
|
-
// gets observation-only admission. No-effect verification may complete the
|
|
210
|
-
// workflow; a verification command that mutates still exhausts after Post.
|
|
211
|
-
const finalVerify = fixtureSession('workflow-final-iteration-verify');
|
|
212
|
-
check(finalVerify.route('Fix this checkout bug with a regression test').status === 0 &&
|
|
213
|
-
finalVerify.control('start').status === 0,
|
|
214
|
-
'#164 final-iteration verification fixture starts Tier 2');
|
|
215
|
-
const finalVerifyLimit = read(finalVerify.file).workflowPlan.limits.maxIterations;
|
|
216
|
-
for (let i = 0; i < finalVerifyLimit; i++) {
|
|
217
|
-
check(finalVerify.gate('Write', { file_path: path.join(repo, 'final-verify-' + i + '.txt') }).status === 0,
|
|
218
|
-
'#164 confirmed mutation fills iteration budget ' + (i + 1) + '/' + finalVerifyLimit);
|
|
219
|
-
}
|
|
220
|
-
check(read(finalVerify.file).budget.counters.iterations === finalVerifyLimit,
|
|
221
|
-
'#164 fixture reaches the exact mutation limit while still running');
|
|
222
|
-
const finalVerifyId = 'toolu_final_iteration_verify';
|
|
223
|
-
check(finalVerify.gate('Bash', { command: 'node run-test.js' }, { tool_use_id: finalVerifyId }).status === 0,
|
|
224
|
-
'#164 no-effect verification shell is admitted at the exact mutation limit');
|
|
225
|
-
check(finalVerify.persist('Bash', { command: 'node run-test.js' }, { stdout: 'tests passed' },
|
|
226
|
-
{ tool_use_id: finalVerifyId }).status === 0,
|
|
227
|
-
'#164 no-effect verification settles without exhausting the budget');
|
|
228
|
-
check(read(finalVerify.file).budget.counters.iterations === finalVerifyLimit &&
|
|
229
|
-
read(finalVerify.file).state === 'running',
|
|
230
|
-
'#164 no-effect verification preserves the full mutation count and running state');
|
|
231
|
-
check(finalVerify.stop().status === 0 && read(finalVerify.file).state === 'satisfied',
|
|
232
|
-
'#164 verification after the final allowed edit can satisfy Stop');
|
|
233
|
-
|
|
234
|
-
const finalMutator = fixtureSession('workflow-final-iteration-mutator');
|
|
235
|
-
check(finalMutator.route('Fix this checkout bug with a regression test').status === 0 &&
|
|
236
|
-
finalMutator.control('start').status === 0,
|
|
237
|
-
'#164 at-limit mutator fixture starts Tier 2');
|
|
238
|
-
const finalMutatorLimit = read(finalMutator.file).workflowPlan.limits.maxIterations;
|
|
239
|
-
for (let i = 0; i < finalMutatorLimit; i++) {
|
|
240
|
-
check(finalMutator.gate('Write', { file_path: path.join(repo, 'final-mutator-' + i + '.txt') }).status === 0,
|
|
241
|
-
'#164 mutator fixture fills iteration budget ' + (i + 1) + '/' + finalMutatorLimit);
|
|
242
|
-
}
|
|
243
|
-
const finalMutatorId = 'toolu_final_iteration_mutator';
|
|
244
|
-
check(finalMutator.gate('Bash', { command: 'node run-test-and-mutate.js' }, { tool_use_id: finalMutatorId }).status === 0,
|
|
245
|
-
'#164 verification-shaped command receives observation-only admission at limit');
|
|
246
|
-
const finalOverflowTarget = path.join(repo, 'final-iteration-overflow.txt');
|
|
247
|
-
fs.writeFileSync(finalOverflowTarget, 'mutation beyond limit\n');
|
|
248
|
-
check(finalMutator.persist('Bash', { command: 'node run-test-and-mutate.js' }, { stdout: 'changed' },
|
|
249
|
-
{ tool_use_id: finalMutatorId }).status === 2,
|
|
250
|
-
'#164 at-limit verification-shaped command that mutates is rejected on observation');
|
|
251
|
-
const finalMutatorState = read(finalMutator.file);
|
|
252
|
-
check(finalMutatorState.state === 'blocked' &&
|
|
253
|
-
finalMutatorState.budget?.state === 'budget-exhausted' &&
|
|
254
|
-
finalMutatorState.budget?.reasonCode === 'iteration-budget-exhausted',
|
|
255
|
-
'#164 observed mutation beyond the limit leaves an auditable blocked workflow');
|
|
256
|
-
fs.unlinkSync(finalOverflowTarget);
|
|
257
|
-
|
|
258
|
-
const taskNotification = '<task-notification><summary>Background command "Run full test suite" failed with exit code 1</summary></task-notification>';
|
|
259
|
-
const unboundNotification = node('harness-everything/scripts/kernel-router.js', { cwd: repo, prompt: taskNotification });
|
|
260
|
-
check(unboundNotification.status === 0 &&
|
|
261
|
-
unboundNotification.stdout.includes('ignored for routing') &&
|
|
262
|
-
!unboundNotification.stdout.includes('WORKFLOW EXECUTION CONTRACT'),
|
|
263
|
-
'host notification without session binding is a routing no-op');
|
|
264
|
-
const noWorkflowNotification = route(taskNotification);
|
|
265
|
-
check(noWorkflowNotification.status === 0 && !fs.existsSync(file),
|
|
266
|
-
'host notification with no active workflow creates no contract');
|
|
267
|
-
check(noWorkflowNotification.stdout.includes('ignored for routing') &&
|
|
268
|
-
!noWorkflowNotification.stdout.includes('iterative-single') &&
|
|
269
|
-
!noWorkflowNotification.stdout.includes('WORKFLOW EXECUTION CONTRACT'),
|
|
270
|
-
'host notification with no active workflow emits no notification-derived routing contract');
|
|
271
|
-
|
|
272
|
-
const routed = route('Refactor the entire repository architecture in dependent stages.');
|
|
273
|
-
check(routed.status === 0, 'prompt hook creates workflow');
|
|
274
|
-
const initial = read(file);
|
|
275
|
-
check(initial.state === 'pending' && initial.workflowId && initial.strategy === 'fable-staged', 'selected route is pending with identity');
|
|
276
|
-
check(initial.requiredInvariants.includes('stage-contracts') && initial.requiredInvariants.includes('isolated-worktree-before-mutation'), 'persisted contract retains full invariant set');
|
|
277
|
-
check(!fs.readFileSync(file, 'utf8').includes('Refactor the entire'), 'state stores prompt hash without prompt text');
|
|
278
|
-
check(gate('Write', { file_path: path.join(linked, 'src.js') }).status === 2, 'Fable entry is required even inside worktree');
|
|
279
|
-
check(stop().status === 2, 'cannot stop before entry');
|
|
280
|
-
check(route('continue').status === 0 && read(file).workflowId === initial.workflowId, 'continuation does not erase active obligation');
|
|
281
|
-
check(route('Update one README typo').status === 0 && read(file).strategy === 'fable-staged', 'simple prompt cannot downgrade unresolved workflow');
|
|
282
|
-
const spec = path.join(sessionDir, 'workflow-stages.json');
|
|
283
|
-
check(gate('Write', { file_path: spec }, repo).status === 0, 'bounded stage specification can be bootstrapped without source mutation');
|
|
284
|
-
fs.linkSync(path.join(repo, 'README.md'), spec);
|
|
285
|
-
check(gate('Write', { file_path: spec }, repo).status === 2, 'bootstrap file cannot alias a primary-tree artifact');
|
|
286
|
-
fs.unlinkSync(spec);
|
|
287
|
-
check(gate('Write', { file_path: file }, repo).status === 2, 'bootstrap exception excludes active workflow state');
|
|
288
|
-
write(spec, stages);
|
|
289
|
-
const command = `node "${path.join(runtimeRoot, 'hooks/scripts/workflow-disposition.js')}" start --session-id ${sessionId}`;
|
|
290
|
-
check(gate('Bash', { command }, repo).status === 0, 'trusted controller can start from bound primary root');
|
|
291
|
-
check(gate('Bash', { command: command + ' && rm src.js' }, repo).status === 2, 'controller prefix cannot hide shell execution');
|
|
292
|
-
const started = control('start');
|
|
293
|
-
check(started.status === 0, 'controller starts a real Fable run: ' + started.stderr);
|
|
294
|
-
const active = read(file);
|
|
295
|
-
const runRoot = path.join(state.getStateRoot(repo), 'fable-runs', active.runId);
|
|
296
|
-
const runFile = path.join(runRoot, 'run.json');
|
|
297
|
-
const manifest = read(runFile);
|
|
298
|
-
check(manifest.workflowId === active.workflowId && manifest.sessionId === sessionId, 'run is explicitly bound to workflow and session');
|
|
299
|
-
const allowedMutation = gate('Write', { file_path: path.join(linked, 'src.js') });
|
|
300
|
-
check(allowedMutation.status === 0, 'entered workflow allows isolated artifact mutation: ' + allowedMutation.stderr);
|
|
301
|
-
for (const invalid of [{ sessionId: null }, { workflowId: 'unrelated' }, { createdAt: 'invalid' }]) {
|
|
302
|
-
write(runFile, { ...manifest, ...invalid });
|
|
303
|
-
check(stop().status === 2, 'uncorrelated run cannot satisfy completion: ' + JSON.stringify(invalid));
|
|
304
|
-
}
|
|
305
|
-
write(runFile, manifest);
|
|
306
|
-
check(control('escape', '--reason-code', 'routine', '--scope', 'task', '--evidence', 'easy').status === 2, 'confidence escape rejected');
|
|
307
|
-
check(control('escape', '--reason-code', 'workflow-uncovered-scope', '--scope', 'task', '--evidence', 'uncovered').status === 2, 'unscoped whole-workflow escape rejected');
|
|
308
|
-
check(control('escape', '--stage-id', 'optional', '--reason-code', 'workflow-uncovered-scope', '--scope', 'external observation', '--evidence', 'fixture external host unavailable').status === 0, 'scoped evidence-backed exception is recorded');
|
|
309
|
-
check(read(file).state === 'running' && stop().status === 2, 'escape leaves covered obligations unresolved');
|
|
310
|
-
check(gate('Write', { file_path: path.join(repo, 'src.js') }, repo).status === 2, 'actual escape command cannot waive isolation');
|
|
311
|
-
check(control('escape', '--stage-id', 'verify', '--reason-code', 'host-capability-unavailable', '--scope', 'verifier', '--evidence', 'missing host').status === 2, 'required verifier cannot be escaped');
|
|
312
|
-
function observe(stage, worker, exitCode) {
|
|
313
|
-
return node('hooks/scripts/contract-test.js', { ...payload, worker_id: worker, tool_name: 'Bash', tool_input: { command: stages.find(item => item.stageId === stage).checkCommand }, tool_response: { exitCode, stdout: 'fixture evidence' } });
|
|
314
|
-
}
|
|
315
|
-
check(observe('verify', 'reviewer', 0).status === 2, 'verifier cannot pass ahead of its dependency');
|
|
316
|
-
check(observe('build', 'builder', 1).status === 2 && stop().status === 2, 'failed objective check prevents completion');
|
|
317
|
-
check(observe('build', 'builder', 0).status === 0, 'failed stage may recover with observed passing evidence');
|
|
318
|
-
check(observe('verify', 'reviewer', 0).status === 0, 'independent verifier evidence is observed');
|
|
319
|
-
const buildFile = path.join(runRoot, 'contracts/build.json');
|
|
320
|
-
const savedBuild = read(buildFile);
|
|
321
|
-
fs.unlinkSync(buildFile);
|
|
322
|
-
check(stop().status === 2, 'deleted contract cannot make remaining directory entries look complete');
|
|
323
|
-
write(buildFile, savedBuild);
|
|
324
|
-
const evidenceFile = path.join(runRoot, 'evidence/build.json');
|
|
325
|
-
const savedEvidence = read(evidenceFile);
|
|
326
|
-
write(evidenceFile, { ...savedEvidence, exitCode: null });
|
|
327
|
-
check(stop().status === 2, 'status pass without observed exit zero is insufficient');
|
|
328
|
-
write(evidenceFile, savedEvidence);
|
|
329
|
-
const mutationState = read(file);
|
|
330
|
-
write(file, { ...mutationState, lastMutationAt: Date.now() + 10000 });
|
|
331
|
-
check(stop().status === 2, 'verification predating a new mutation is stale');
|
|
332
|
-
write(file, mutationState);
|
|
333
|
-
check(stop().status === 0 && read(file).state === 'satisfied', 'all covered stages and verifier resolve workflow to satisfied');
|
|
334
|
-
const satisfiedWorkflow = read(file);
|
|
335
|
-
const satisfiedNotification = route(taskNotification);
|
|
336
|
-
check(satisfiedNotification.status === 0 &&
|
|
337
|
-
read(file).workflowId === satisfiedWorkflow.workflowId &&
|
|
338
|
-
read(file).state === 'satisfied',
|
|
339
|
-
'satisfied workflow followed by host notification keeps the same satisfied contract');
|
|
340
|
-
check(!satisfiedNotification.stdout.includes('Run full test suite') &&
|
|
341
|
-
satisfiedNotification.stdout.includes('routing unchanged'),
|
|
342
|
-
'satisfied host notification text is not routed as new work');
|
|
343
|
-
check(route('Refactor the entire repository architecture in dependent stages.').status === 0 && read(file).workflowId !== initial.workflowId, 'next completed-task boundary creates a fresh workflow');
|
|
344
|
-
check(stop({ stop_hook_active: true }).status === 0 && read(file).state === 'blocked', 'Stop retry reports blocked instead of faking completion');
|
|
345
|
-
check(gate('Write', { file_path: path.join(linked, 'src.js') }).status === 2, 'blocked state still prohibits mutation');
|
|
346
|
-
for (let i = 0; i < 3; i++) { check(control('start').status === 0, 'bounded replan attempt ' + i); control('block', '--evidence', 'fixture blocker'); }
|
|
347
|
-
check(control('start').status === 2 && read(file).state === 'blocked', 'exhausted replan budget remains blocked');
|
|
348
|
-
check(read(file).budget?.state === 'budget-exhausted' && read(file).budget?.reasonCode === 'replan-budget-exhausted', 'Fable replan exhaustion is auditable before recovery');
|
|
349
|
-
const dispositionPath = path.join(runtimeRoot, 'hooks/scripts/workflow-disposition.js');
|
|
350
|
-
const resetCommand = `node "${dispositionPath}" reset-budget --session-id ${sessionId} --evidence fixture-fable-budget-reset`;
|
|
351
|
-
check(gate('Bash', { command: resetCommand }, repo).status === 0, 'blocked Fable workflow admits trusted reset-budget controller through gate');
|
|
352
|
-
check(gate('Bash', { command: resetCommand + ' && echo bypass' }, repo).status === 2, 'controller shell chaining is not exempted');
|
|
353
|
-
check(gate('Bash', { command: resetCommand + ' | echo bypass' }, repo).status === 2, 'controller pipe composition is not exempted');
|
|
354
|
-
check(gate('Bash', { command: `node "${dispositionPath}" reset-budget --session-id ${sessionId} --evidence $(echo bypass)` }, repo).status === 2, 'controller command substitution is not exempted');
|
|
355
|
-
check(gate('Bash', { command: `node "${path.join(runtimeRoot, 'hooks/scripts/not-workflow-disposition.js')}" reset-budget --session-id ${sessionId} --evidence wrong-path` }, repo).status === 2, 'wrong controller script path is not exempted');
|
|
356
|
-
check(control('reset-budget', '--evidence', 'fixture-fable-budget-reset').status === 0, 'reset-budget executes after gate admission');
|
|
357
|
-
const fableReset = read(file);
|
|
358
|
-
check(fableReset.state === 'pending' && fableReset.budget?.state === 'active' && fableReset.budget?.epoch === 1, 'budget reset returns Fable workflow to pending with audited epoch');
|
|
359
|
-
check(gate('Write', { file_path: path.join(repo, 'still-primary.js') }, repo).status === 2, 'budget reset does not weaken primary-tree worktree isolation');
|
|
360
|
-
const restartCommand = `node "${dispositionPath}" start --session-id ${sessionId}`;
|
|
361
|
-
check(gate('Bash', { command: restartCommand }, repo).status === 0, 'trusted start controller remains admitted after reset');
|
|
362
|
-
check(control('start').status === 0 && read(file).state === 'running', 'Fable workflow resumes after audited budget reset');
|
|
363
|
-
const revisionCommand = `node "${dispositionPath}" revision --session-id ${sessionId} --evidence fixture-revision`;
|
|
364
|
-
check(gate('Bash', { command: revisionCommand }, repo).status === 0, 'revision controller is admitted through workflow gate');
|
|
365
|
-
check(control('revision', '--evidence', 'fixture-revision').status === 0 && read(file).budget?.counters?.revisionRounds === 1, 'revision controller reaches budget accounting');
|
|
366
|
-
fs.writeFileSync(file, '{broken');
|
|
367
|
-
check(route('continue').status === 2 && fs.readFileSync(file, 'utf8') === '{broken', 'router cannot overwrite unreadable unresolved state');
|
|
368
|
-
check(stop().status === 2, 'malformed state cannot silently pass completion');
|
|
369
|
-
write(file, { state: 'active' });
|
|
370
|
-
check(gate('Write', { file_path: path.join(linked, 'src.js') }).status === 2 && stop().status === 2, 'partial JSON state cannot bypass workflow gates');
|
|
371
|
-
fs.unlinkSync(file);
|
|
372
|
-
check(route('Fix this checkout bug with a regression test').status === 0 && read(file).strategy === 'iterative-single', 'bounded fix selects iterative lifecycle');
|
|
373
|
-
const activeTier2 = read(file);
|
|
374
|
-
check(activeTier2.tier === 'tier2' && activeTier2.mutationIsolation?.required === false && !workflowRuntime.isMajorWorkflow(activeTier2), 'Tier 2 iterative workflow starts non-major');
|
|
375
|
-
check(route('Refactor the entire repository architecture in dependent stages.').status === 0, 'stronger Tier 3 route is retained for explicit replan');
|
|
376
|
-
const queuedTier3 = read(file);
|
|
377
|
-
check(queuedTier3.state === 'blocked' && queuedTier3.pendingPlan?.tier === 'tier3' && queuedTier3.pendingPlan?.mutationIsolation?.required === true, 'stronger Tier 3 plan is queued as pending only');
|
|
378
|
-
check(queuedTier3.workflowPlan?.tier === 'tier2' && queuedTier3.strategy === 'iterative-single' &&
|
|
379
|
-
queuedTier3.mutationIsolation?.required === false, 'pending Tier 3 plan does not overwrite active Tier 2 fields');
|
|
380
|
-
check(!workflowRuntime.isMajorWorkflow(queuedTier3), 'major-workflow classification follows the authoritative active plan, not pending metadata');
|
|
381
|
-
write(spec, stages);
|
|
382
|
-
check(control('start').status === 0, 'explicit replan activates queued Tier 3 plan');
|
|
383
|
-
const promotedTier3 = read(file);
|
|
384
|
-
check(promotedTier3.workflowPlan?.tier === 'tier3' && promotedTier3.strategy.startsWith('fable-') &&
|
|
385
|
-
promotedTier3.mutationIsolation?.required === true && !promotedTier3.pendingPlan, 'pending Tier 3 isolation becomes active atomically on start');
|
|
386
|
-
check(workflowRuntime.isMajorWorkflow(promotedTier3), 'major-workflow classification flips only after explicit activation');
|
|
387
|
-
check(gate('Write', { file_path: path.join(repo, 'premature-primary.js') }, repo).status === 2, 'activated Tier 3 plan still blocks primary-tree mutation');
|
|
388
|
-
|
|
389
|
-
// #163: a queued Tier-3 plan can intentionally remain single-agent when
|
|
390
|
-
// Fable is prohibited. Explicit start must still promote the whole plan,
|
|
391
|
-
// including isolation and budget metadata, before any mutation is admitted.
|
|
392
|
-
fs.unlinkSync(file);
|
|
393
|
-
check(route('Fix this checkout bug with a regression test').status === 0 && read(file).strategy === 'iterative-single',
|
|
394
|
-
'#163 fixture starts from a Tier 2 iterative workflow');
|
|
395
|
-
check(control('start').status === 0 && read(file).state === 'running',
|
|
396
|
-
'#163 fixture activates Tier 2 before a stronger route arrives');
|
|
397
|
-
check(route('Refactor the entire authentication architecture across all services and migrate the database schema without fable').status === 0,
|
|
398
|
-
'#163 stronger Tier 3 route without Fable is retained for explicit replan');
|
|
399
|
-
const queuedNonFableTier3 = read(file);
|
|
400
|
-
check(queuedNonFableTier3.state === 'blocked' &&
|
|
401
|
-
queuedNonFableTier3.pendingPlan?.tier === 'tier3' &&
|
|
402
|
-
queuedNonFableTier3.pendingPlan?.strategy === 'iterative-single' &&
|
|
403
|
-
queuedNonFableTier3.pendingPlan?.mutationIsolation?.required === true,
|
|
404
|
-
'#163 queued Tier 3 plan remains non-Fable but still requires isolation');
|
|
405
|
-
write(file, {
|
|
406
|
-
...queuedNonFableTier3,
|
|
407
|
-
budget: {
|
|
408
|
-
...queuedNonFableTier3.budget,
|
|
409
|
-
limits: { ...queuedNonFableTier3.budget.limits, maxIterations: 1 },
|
|
410
|
-
},
|
|
411
|
-
});
|
|
412
|
-
check(control('start').status === 0, '#163 explicit start activates queued non-Fable Tier 3 plan');
|
|
413
|
-
const promotedNonFableTier3 = read(file);
|
|
414
|
-
check(promotedNonFableTier3.workflowPlan?.tier === 'tier3' &&
|
|
415
|
-
promotedNonFableTier3.tier === 'tier3' &&
|
|
416
|
-
promotedNonFableTier3.strategy === 'iterative-single' &&
|
|
417
|
-
promotedNonFableTier3.mutationIsolation?.required === true &&
|
|
418
|
-
!promotedNonFableTier3.pendingPlan,
|
|
419
|
-
'#163 non-Fable Tier 3 plan replaces all authoritative active-plan fields');
|
|
420
|
-
check(promotedNonFableTier3.budget?.limits?.maxIterations === promotedNonFableTier3.workflowPlan?.limits?.maxIterations &&
|
|
421
|
-
promotedNonFableTier3.budget?.limits?.maxIterations !== 1,
|
|
422
|
-
'#163 plan activation re-derives budget limits from the promoted plan');
|
|
423
|
-
check(workflowRuntime.isMajorWorkflow(promotedNonFableTier3),
|
|
424
|
-
'#163 non-Fable Tier 3 promotion enables major-workflow isolation');
|
|
425
|
-
check(gate('Write', { file_path: path.join(repo, 'non-fable-primary.js') }, repo).status === 2,
|
|
426
|
-
'#163 promoted non-Fable Tier 3 blocks primary-tree mutation');
|
|
427
|
-
check(gate('Write', { file_path: path.join(linked, 'non-fable-linked.js') }, linked).status === 0,
|
|
428
|
-
'#163 promoted non-Fable Tier 3 admits linked-worktree mutation');
|
|
429
|
-
|
|
430
|
-
fs.unlinkSync(file);
|
|
431
|
-
check(route('Fix this checkout bug with a regression test').status === 0 && read(file).strategy === 'iterative-single', 'fresh bounded fix returns to Tier 2 iterative lifecycle');
|
|
432
|
-
let tier2 = read(file);
|
|
433
|
-
const tier2WorkflowId = tier2.workflowId;
|
|
434
|
-
write(file, { ...tier2, state: 'deferred' });
|
|
435
|
-
const deferredNotification = route(taskNotification);
|
|
436
|
-
check(deferredNotification.status === 0 &&
|
|
437
|
-
read(file).workflowId === tier2WorkflowId &&
|
|
438
|
-
read(file).state === 'deferred',
|
|
439
|
-
'deferred workflow followed by host notification keeps the same deferred contract');
|
|
440
|
-
check(!deferredNotification.stdout.includes('Run full test suite') &&
|
|
441
|
-
deferredNotification.stdout.includes('routing unchanged'),
|
|
442
|
-
'deferred host notification text is not routed as new work');
|
|
443
|
-
write(file, tier2);
|
|
444
|
-
|
|
445
|
-
const iterationLimit = read(file).workflowPlan.limits.maxIterations;
|
|
446
|
-
check(Number.isInteger(iterationLimit) && iterationLimit > 0, 'iterative route exposes a numeric iteration budget');
|
|
447
|
-
|
|
448
|
-
// #165: direct Edit/Write/apply_patch accounting follows workspace effects.
|
|
449
|
-
// Bookkeeping outside the repository must not consume Tier-2 iterations or
|
|
450
|
-
// advance workflow/Stop-gate mutation milestones.
|
|
451
|
-
const handoffFile = path.join(sessionDir, 'handoff-state.json');
|
|
452
|
-
const outsideTarget = path.join(temp, 'scratch-note.txt');
|
|
453
|
-
const beforeOutside = read(file);
|
|
454
|
-
const beforeOutsideIterations = beforeOutside.budget?.counters?.iterations || 0;
|
|
455
|
-
const beforeOutsideMutationAt = beforeOutside.lastMutationAt || 0;
|
|
456
|
-
const beforeOutsideEditAt = fs.existsSync(handoffFile) ? (read(handoffFile).lastEditAt || 0) : 0;
|
|
457
|
-
for (let i = 0; i < 3; i++) {
|
|
458
|
-
check(gate('Write', { file_path: outsideTarget }, repo).status === 0,
|
|
459
|
-
'out-of-workspace Write is admitted without Tier-2 mutation accounting');
|
|
460
|
-
fs.writeFileSync(outsideTarget, 'scratch ' + i + '\n');
|
|
461
|
-
const outsideResponse = i === 0
|
|
462
|
-
? { stderr: 'warning: successful bookkeeping write emitted diagnostic text' }
|
|
463
|
-
: {};
|
|
464
|
-
check(persistDirect('Write', { file_path: outsideTarget }, repo, outsideResponse).status === 0,
|
|
465
|
-
'out-of-workspace Write post-tool state persists without mutation milestone');
|
|
466
|
-
}
|
|
467
|
-
const afterOutside = read(file);
|
|
468
|
-
const afterOutsideHandoff = read(handoffFile);
|
|
469
|
-
check((afterOutside.budget?.counters?.iterations || 0) === beforeOutsideIterations &&
|
|
470
|
-
(afterOutside.lastMutationAt || 0) === beforeOutsideMutationAt &&
|
|
471
|
-
(afterOutsideHandoff.lastEditAt || 0) === beforeOutsideEditAt &&
|
|
472
|
-
afterOutsideHandoff.status !== 'failed',
|
|
473
|
-
'out-of-workspace Writes leave iterations/milestones unchanged and PostToolUse stderr is not a failure');
|
|
474
|
-
check(stop().status === 0, 'out-of-workspace-only bookkeeping does not force verification before Stop');
|
|
475
|
-
|
|
476
|
-
check(route('Fix this checkout bug with a regression test').status === 0 && read(file).strategy === 'iterative-single',
|
|
477
|
-
'real follow-up user prompt after notification/accounting checks routes normally');
|
|
478
|
-
let observedDirectIterations = read(file).budget?.counters?.iterations || 0;
|
|
479
|
-
const internalTarget = path.join(repo, 'direct-inside.txt');
|
|
480
|
-
const beforeInternalMutationAt = read(file).lastMutationAt || 0;
|
|
481
|
-
check(gate('Write', { file_path: internalTarget }, repo).status === 0, 'in-workspace Write is admitted');
|
|
482
|
-
fs.writeFileSync(internalTarget, 'inside\n');
|
|
483
|
-
check(persistDirect('Write', { file_path: internalTarget }, repo).status === 0, 'in-workspace Write post-tool state persists');
|
|
484
|
-
check(read(file).budget.counters.iterations === ++observedDirectIterations &&
|
|
485
|
-
read(file).lastMutationAt >= beforeInternalMutationAt &&
|
|
486
|
-
read(handoffFile).lastEditAt > beforeOutsideEditAt,
|
|
487
|
-
'in-workspace Write consumes exactly one iteration and advances mutation milestones');
|
|
488
|
-
|
|
489
|
-
const mixedPatch = [
|
|
490
|
-
'*** Begin Patch',
|
|
491
|
-
`*** Update File: ${outsideTarget}`,
|
|
492
|
-
'@@',
|
|
493
|
-
'-scratch',
|
|
494
|
-
'+scratch updated',
|
|
495
|
-
`*** Update File: ${path.join(repo, 'README.md')}`,
|
|
496
|
-
'@@',
|
|
497
|
-
'-fixture',
|
|
498
|
-
'+fixture updated',
|
|
499
|
-
'*** End Patch',
|
|
500
|
-
].join('\n');
|
|
501
|
-
check(gate('apply_patch', { patch: mixedPatch }, repo).status === 0, 'mixed apply_patch is admitted');
|
|
502
|
-
check(persistDirect('apply_patch', { patch: mixedPatch }, repo).status === 0, 'mixed apply_patch post-tool state persists');
|
|
503
|
-
check(read(file).budget.counters.iterations === ++observedDirectIterations,
|
|
504
|
-
'mixed apply_patch counts exactly once when any target is inside the workspace');
|
|
505
|
-
|
|
506
|
-
const pureOutsidePatch = [
|
|
507
|
-
'*** Begin Patch',
|
|
508
|
-
`*** Update File: ${outsideTarget}`,
|
|
509
|
-
'@@',
|
|
510
|
-
'-scratch updated',
|
|
511
|
-
'+scratch external only',
|
|
512
|
-
'*** End Patch',
|
|
513
|
-
].join('\n');
|
|
514
|
-
const beforePureOutside = read(file);
|
|
515
|
-
const beforePureOutsideEditAt = read(handoffFile).lastEditAt || 0;
|
|
516
|
-
check(gate('apply_patch', { patch: pureOutsidePatch }, repo).status === 0, 'pure external apply_patch is admitted');
|
|
517
|
-
check(persistDirect('apply_patch', { patch: pureOutsidePatch }, repo).status === 0, 'pure external apply_patch post-tool state persists');
|
|
518
|
-
check(read(file).budget.counters.iterations === observedDirectIterations &&
|
|
519
|
-
read(file).lastMutationAt === beforePureOutside.lastMutationAt &&
|
|
520
|
-
(read(handoffFile).lastEditAt || 0) === beforePureOutsideEditAt,
|
|
521
|
-
'pure external apply_patch consumes no iteration and advances no mutation milestone');
|
|
522
|
-
|
|
523
|
-
const linkedTarget = path.join(linked, 'direct-linked.txt');
|
|
524
|
-
check(gate('Write', { file_path: linkedTarget }, linked).status === 0,
|
|
525
|
-
'Write inside a linked worktree is treated as workspace mutation');
|
|
526
|
-
fs.writeFileSync(linkedTarget, 'linked\n');
|
|
527
|
-
check(persistDirect('Write', { file_path: linkedTarget }, linked).status === 0,
|
|
528
|
-
'linked-worktree Write post-tool state persists');
|
|
529
|
-
check(read(file).budget.counters.iterations === ++observedDirectIterations,
|
|
530
|
-
'linked-worktree Write consumes exactly one iteration');
|
|
531
|
-
|
|
532
|
-
// Keep the #165 regression fixture from consuming the budget used by the
|
|
533
|
-
// pre-existing lifecycle/exhaustion assertions below.
|
|
534
|
-
fs.unlinkSync(internalTarget);
|
|
535
|
-
fs.unlinkSync(linkedTarget);
|
|
536
|
-
fs.unlinkSync(file);
|
|
537
|
-
if (fs.existsSync(handoffFile)) fs.unlinkSync(handoffFile);
|
|
538
|
-
check(route('Fix this checkout bug with a regression test').status === 0 &&
|
|
539
|
-
read(file).strategy === 'iterative-single' &&
|
|
540
|
-
(read(file).budget?.counters?.iterations || 0) === 0,
|
|
541
|
-
'#165 regression fixture resets into a fresh Tier-2 lifecycle baseline');
|
|
542
|
-
|
|
543
|
-
// #155/#157 regression lock: read-only shell composition, including quoted
|
|
544
|
-
// and escaped separator characters inside arguments, must never look like an
|
|
545
|
-
// iterative mutation or advance the mutation clock.
|
|
546
|
-
const beforeReadOnly = read(file);
|
|
547
|
-
const readOnlyIterations = beforeReadOnly.budget?.counters?.iterations || 0;
|
|
548
|
-
const readOnlyMutationAt = beforeReadOnly.lastMutationAt || 0;
|
|
549
|
-
const quotedReadOnly = [
|
|
550
|
-
'git status --short; git log --oneline -1',
|
|
551
|
-
'gh issue view 1 --repo octocat/hello-world --json number,title 2>&1',
|
|
552
|
-
'grep -n "fixture\\|missing" README.md | head -20',
|
|
553
|
-
"grep -n 'fixture|missing' README.md | head -20",
|
|
554
|
-
'echo "fixture|missing" | grep fixture',
|
|
555
|
-
"grep 'fixture;missing' README.md | head -1",
|
|
556
|
-
];
|
|
557
|
-
for (let i = 0; i < iterationLimit + 2; i++) {
|
|
558
|
-
const command = quotedReadOnly[i % quotedReadOnly.length];
|
|
559
|
-
check(gate('Bash', { command }, repo).status === 0, 'quoted/escaped read-only shell command stays outside iteration budget: ' + command);
|
|
560
|
-
}
|
|
561
|
-
const afterReadOnly = read(file);
|
|
562
|
-
check((afterReadOnly.budget?.counters?.iterations || 0) === readOnlyIterations,
|
|
563
|
-
'read-only shell composition does not consume iterative budget');
|
|
564
|
-
check((afterReadOnly.lastMutationAt || 0) === readOnlyMutationAt,
|
|
565
|
-
'read-only shell composition does not advance lastMutationAt');
|
|
566
|
-
|
|
567
|
-
// #159: safety classification is not mutation evidence. Commands that are
|
|
568
|
-
// not proven read-only are admitted under an opaque workspace probe while
|
|
569
|
-
// capacity remains, then accounted only if the visible workspace changed.
|
|
570
|
-
const untrustedReads = [
|
|
571
|
-
'git fetch origin --quiet 2>&1; git status; git log --oneline -1',
|
|
572
|
-
'git remote -v',
|
|
573
|
-
'gh run view 1 --json status',
|
|
574
|
-
'node -e "console.log(42)"',
|
|
575
|
-
];
|
|
576
|
-
for (const command of untrustedReads) {
|
|
577
|
-
const before = read(file);
|
|
578
|
-
const call = beginShell(command);
|
|
579
|
-
check(call.result.status === 0, 'untrusted read/query is admitted under observation: ' + command);
|
|
580
|
-
const probes = probeFiles();
|
|
581
|
-
check(probes.length === 1 && !fs.readFileSync(path.join(probeDir, probes[0]), 'utf8').includes(command),
|
|
582
|
-
'mutation probe persists only opaque identity, not raw command text');
|
|
583
|
-
check(persistShell(call).status === 0, 'no-effect shell probe resolves through state-persist: ' + command);
|
|
584
|
-
check(probeFiles().length === 0, 'completed shell call removes its mutation probe');
|
|
585
|
-
const after = read(file);
|
|
586
|
-
check((after.budget?.counters?.iterations || 0) === (before.budget?.counters?.iterations || 0),
|
|
587
|
-
'no-effect untrusted shell command consumes no iteration');
|
|
588
|
-
check((after.lastMutationAt || 0) === (before.lastMutationAt || 0),
|
|
589
|
-
'no-effect untrusted shell command does not advance lastMutationAt');
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
// #161: Post payload cwd may differ from Pre payload cwd after a shell
|
|
593
|
-
// command changes directories. Stable tool_use_id identity plus the stored
|
|
594
|
-
// Pre-time observation cwd must settle the original probe.
|
|
595
|
-
const driftCwd = path.join(repo, 'hooks');
|
|
596
|
-
fs.mkdirSync(driftCwd, { recursive: true });
|
|
597
|
-
const driftNoEffectBefore = read(file);
|
|
598
|
-
const driftNoEffect = beginShell('cd hooks && node -e "process.exit(0)"', repo);
|
|
599
|
-
check(driftNoEffect.result.status === 0 && probeFiles().length === 1,
|
|
600
|
-
'#161 cwd-drift no-effect call registers one probe');
|
|
601
|
-
const driftNoEffectPost = node('hooks/scripts/state-persist.js', {
|
|
602
|
-
...payload,
|
|
603
|
-
cwd: driftCwd,
|
|
604
|
-
hook_event_name: 'PostToolUse',
|
|
605
|
-
tool_name: 'Bash',
|
|
606
|
-
tool_use_id: driftNoEffect.toolUseId,
|
|
607
|
-
tool_input: { command: driftNoEffect.command },
|
|
608
|
-
tool_response: { stdout: 'ok' },
|
|
609
|
-
});
|
|
610
|
-
check(driftNoEffectPost.status === 0 && probeFiles().length === 0,
|
|
611
|
-
'#161 Post with drifted cwd settles the Pre-time probe');
|
|
612
|
-
check((read(file).budget?.counters?.iterations || 0) === (driftNoEffectBefore.budget?.counters?.iterations || 0),
|
|
613
|
-
'#161 cwd-drift no-effect shell consumes zero iterations');
|
|
614
|
-
|
|
615
|
-
const driftMutationBefore = read(file);
|
|
616
|
-
const driftMutation = beginShell('cd hooks && node mutate-from-subdir.js', repo);
|
|
617
|
-
check(driftMutation.result.status === 0, '#161 cwd-drift mutator is admitted under observation');
|
|
618
|
-
fs.writeFileSync(path.join(repo, 'drift-mutated.txt'), 'changed under cwd drift\n');
|
|
619
|
-
const driftMutationPost = node('hooks/scripts/state-persist.js', {
|
|
620
|
-
...payload,
|
|
621
|
-
cwd: driftCwd,
|
|
622
|
-
hook_event_name: 'PostToolUse',
|
|
623
|
-
tool_name: 'Bash',
|
|
624
|
-
tool_use_id: driftMutation.toolUseId,
|
|
625
|
-
tool_input: { command: driftMutation.command },
|
|
626
|
-
tool_response: { stdout: 'changed' },
|
|
627
|
-
});
|
|
628
|
-
const driftMutationAfter = read(file);
|
|
629
|
-
check(driftMutationPost.status === 0 && probeFiles().length === 0 &&
|
|
630
|
-
(driftMutationAfter.budget?.counters?.iterations || 0) === (driftMutationBefore.budget?.counters?.iterations || 0) + 1 &&
|
|
631
|
-
driftMutationAfter.lastMutationAt >= (driftMutationBefore.lastMutationAt || 0),
|
|
632
|
-
'#161 cwd-drift real mutation is observed exactly once');
|
|
633
|
-
|
|
634
|
-
// A live command may legitimately run longer than the old 1s grace period.
|
|
635
|
-
// Its probe must remain reserved until the declared tool timeout expires.
|
|
636
|
-
const longRunningStateBefore = read(file);
|
|
637
|
-
const longRunningBefore = longRunningStateBefore.budget.counters.iterations;
|
|
638
|
-
const longRunningA = beginShell('node long-running-mutator.js', repo, { timeout: 60000 });
|
|
639
|
-
check(longRunningA.result.status === 0 && probeFiles().length === 1,
|
|
640
|
-
'#161 long-running call registers one timeout-bound probe');
|
|
641
|
-
{
|
|
642
|
-
const target = path.join(probeDir, probeFiles()[0]);
|
|
643
|
-
const probe = read(target);
|
|
644
|
-
write(target, { ...probe, observedAt: Date.now() - 2000 });
|
|
645
|
-
}
|
|
646
|
-
const concurrentB = beginShell('node concurrent-observer.js', repo, { timeout: 60000 });
|
|
647
|
-
check(concurrentB.result.status === 0 && probeFiles().length === 2,
|
|
648
|
-
'#161 a second Pre does not reclaim an in-flight probe merely because it is older than 1s');
|
|
649
|
-
fs.writeFileSync(path.join(repo, 'long-running-mutated.txt'), 'changed by long-running A\n');
|
|
650
|
-
check(persistShell(longRunningA).status === 0 &&
|
|
651
|
-
read(file).budget.counters.iterations === longRunningBefore + 1,
|
|
652
|
-
'#161 long-running A still settles and records its later mutation');
|
|
653
|
-
check(denyShell(concurrentB).status === 0 && probeFiles().length === 0,
|
|
654
|
-
'#161 concurrent non-mutating probe can be denied independently after A settles');
|
|
655
|
-
fs.rmSync(path.join(repo, 'long-running-mutated.txt'), { force: true });
|
|
656
|
-
write(file, longRunningStateBefore);
|
|
657
|
-
check(read(file).budget.counters.iterations === longRunningBefore,
|
|
658
|
-
'#161 long-running regression restores the shared fixture budget before later boundary tests');
|
|
659
|
-
|
|
660
|
-
// Simulate default-mode denial / sibling PreToolUse blocking: Pre fires but
|
|
661
|
-
// neither PostToolUse nor PermissionDenied follows. Force each orphan past
|
|
662
|
-
// its timeout-bound reclaim deadline so the next real Pre can reclaim it
|
|
663
|
-
// deterministically without sleeping in CI.
|
|
664
|
-
const orphanStartIterations = read(file).budget.counters.iterations;
|
|
665
|
-
for (let i = 0; i < iterationLimit; i++) {
|
|
666
|
-
const orphan = beginShell('node rejected-before-exec-' + i + '.js', repo);
|
|
667
|
-
check(orphan.result.status === 0, '#161 Pre-only orphan ' + (i + 1) + ' is initially admitted');
|
|
668
|
-
ageProbeFiles();
|
|
669
|
-
}
|
|
670
|
-
check(workflowRuntime.mutationProbeReservations(workflowRuntime.loadWorkflow(payload)) === 0,
|
|
671
|
-
'#161 stale Pre-only probes no longer reserve iteration capacity');
|
|
672
|
-
const harmlessAfterOrphans = beginShell('node harmless-after-orphans.js', repo);
|
|
673
|
-
check(harmlessAfterOrphans.result.status === 0 && probeFiles().length === 1,
|
|
674
|
-
'#161 next harmless untrusted shell is admitted after maxIterations Pre-only orphans');
|
|
675
|
-
check(persistShell(harmlessAfterOrphans).status === 0 && probeFiles().length === 0,
|
|
676
|
-
'#161 orphan reclamation leaves only the live probe and it settles normally');
|
|
677
|
-
check(read(file).budget.counters.iterations === orphanStartIterations,
|
|
678
|
-
'#161 rejected/no-effect orphan sequence consumes zero iterations');
|
|
679
|
-
|
|
680
|
-
// If an orphan really did mutate the workspace, reclaim must account for it
|
|
681
|
-
// once rather than silently dropping evidence.
|
|
682
|
-
const staleMutation = beginShell('node orphaned-mutator.js', repo);
|
|
683
|
-
check(staleMutation.result.status === 0, '#161 stale-mutator probe is registered');
|
|
684
|
-
ageProbeFiles();
|
|
685
|
-
const staleMutationBefore = read(file);
|
|
686
|
-
fs.writeFileSync(path.join(repo, 'stale-probe-mutated.txt'), 'changed before missing Post\n');
|
|
687
|
-
const reclaimTrigger = beginShell('node reclaim-trigger.js', repo);
|
|
688
|
-
const reclaimed = read(file);
|
|
689
|
-
check(reclaimTrigger.result.status === 0 &&
|
|
690
|
-
reclaimed.budget.counters.iterations === staleMutationBefore.budget.counters.iterations + 1 &&
|
|
691
|
-
reclaimed.lastMutationAt >= (staleMutationBefore.lastMutationAt || 0),
|
|
692
|
-
'#161 stale probe with workspace change is reclaimed as exactly one mutation');
|
|
693
|
-
check(persistShell(reclaimTrigger).status === 0 &&
|
|
694
|
-
read(file).budget.counters.iterations === reclaimed.budget.counters.iterations &&
|
|
695
|
-
probeFiles().length === 0,
|
|
696
|
-
'#161 reclaimed stale mutation is not double-counted by the trigger Post');
|
|
697
|
-
|
|
698
|
-
const deniedCall = beginShell('node denied-by-auto-mode.js');
|
|
699
|
-
check(deniedCall.result.status === 0 && probeFiles().length === 1,
|
|
700
|
-
'auto-mode candidate reserves mutation capacity before permission decision');
|
|
701
|
-
check(denyShell(deniedCall).status === 0 && probeFiles().length === 0,
|
|
702
|
-
'PermissionDenied releases the exact tool-use mutation probe');
|
|
703
|
-
const parallelA = beginShell('node repeated-read-only-wrapper.js');
|
|
704
|
-
const parallelB = beginShell('node repeated-read-only-wrapper.js');
|
|
705
|
-
check(parallelA.result.status === 0 && parallelB.result.status === 0 && probeFiles().length === 2,
|
|
706
|
-
'identical concurrent shell commands receive distinct tool-use probes');
|
|
707
|
-
check(denyShell(parallelA).status === 0 && persistShell(parallelB).status === 0 && probeFiles().length === 0,
|
|
708
|
-
'parallel tool-use probes settle independently without reservation leakage');
|
|
709
|
-
|
|
710
|
-
const metadataFetch = 'git fetch . HEAD:refs/remotes/origin/harness-probe';
|
|
711
|
-
const beforeMetadataFetch = read(file);
|
|
712
|
-
const metadataFetchCall = beginShell(metadataFetch);
|
|
713
|
-
check(metadataFetchCall.result.status === 0,
|
|
714
|
-
'metadata-only git fetch is admitted under observation');
|
|
715
|
-
git(['fetch', '.', 'HEAD:refs/remotes/origin/harness-probe']);
|
|
716
|
-
check(persistShell(metadataFetchCall).status === 0, 'real metadata-only git fetch probe resolves');
|
|
717
|
-
check((read(file).budget?.counters?.iterations || 0) === (beforeMetadataFetch.budget?.counters?.iterations || 0) &&
|
|
718
|
-
(read(file).lastMutationAt || 0) === (beforeMetadataFetch.lastMutationAt || 0),
|
|
719
|
-
'real git fetch changes Git metadata without consuming a code iteration');
|
|
720
|
-
|
|
721
|
-
let observedIterations = read(file).budget?.counters?.iterations || 0;
|
|
722
|
-
const mutateTracked = 'node mutate-tracked.js';
|
|
723
|
-
const mutateTrackedCall = beginShell(mutateTracked);
|
|
724
|
-
check(mutateTrackedCall.result.status === 0, 'unknown tracked-file mutator is admitted under observation');
|
|
725
|
-
fs.writeFileSync(path.join(repo, 'README.md'), 'fixture changed once\n');
|
|
726
|
-
check(persistShell(mutateTrackedCall).status === 0, 'tracked-file mutation is observed');
|
|
727
|
-
check(read(file).budget.counters.iterations === ++observedIterations, 'tracked-file mutation consumes exactly one iteration');
|
|
728
|
-
const firstObservedAt = read(file).lastMutationAt;
|
|
729
|
-
|
|
730
|
-
const mutateDirty = 'node mutate-dirty-again.js';
|
|
731
|
-
const mutateDirtyCall = beginShell(mutateDirty);
|
|
732
|
-
check(mutateDirtyCall.result.status === 0, 'unknown already-dirty mutator is admitted under observation');
|
|
733
|
-
fs.writeFileSync(path.join(repo, 'README.md'), 'fixture changed twice\n');
|
|
734
|
-
check(persistShell(mutateDirtyCall).status === 0, 'already-dirty content change is observed');
|
|
735
|
-
check(read(file).budget.counters.iterations === ++observedIterations && read(file).lastMutationAt >= firstObservedAt,
|
|
736
|
-
'already-dirty content change consumes one new iteration');
|
|
737
|
-
|
|
738
|
-
const createUntracked = 'node create-untracked.js';
|
|
739
|
-
const createUntrackedCall = beginShell(createUntracked);
|
|
740
|
-
check(createUntrackedCall.result.status === 0, 'unknown untracked-file mutator is admitted under observation');
|
|
741
|
-
fs.writeFileSync(path.join(repo, 'observed-untracked.txt'), 'one\n');
|
|
742
|
-
check(persistShell(createUntrackedCall).status === 0, 'untracked-file creation is observed');
|
|
743
|
-
check(read(file).budget.counters.iterations === ++observedIterations, 'untracked-file creation consumes exactly one iteration');
|
|
744
|
-
|
|
745
|
-
const failedMutation = 'node fail-after-write.js';
|
|
746
|
-
const failedMutationCall = beginShell(failedMutation);
|
|
747
|
-
check(failedMutationCall.result.status === 0, 'failing shell mutator is admitted under observation');
|
|
748
|
-
fs.writeFileSync(path.join(repo, 'observed-failed.txt'), 'written before failure\n');
|
|
749
|
-
check(persistShell(failedMutationCall, { error: 'Exit code 7\nfixture failure' }, 'PostToolUseFailure').status === 0,
|
|
750
|
-
'failed shell command still reports workspace mutation through failure lifecycle');
|
|
751
|
-
check(read(file).budget.counters.iterations === ++observedIterations,
|
|
752
|
-
'failed shell command that changed workspace still consumes one iteration');
|
|
753
|
-
|
|
754
|
-
const beforeStageOnly = read(file);
|
|
755
|
-
const stageOnly = 'git add README.md observed-untracked.txt';
|
|
756
|
-
const stageOnlyCall = beginShell(stageOnly);
|
|
757
|
-
check(stageOnlyCall.result.status === 0, 'staging-only git command is observed instead of pre-counted');
|
|
758
|
-
git(['add', 'README.md', 'observed-untracked.txt']);
|
|
759
|
-
check(persistShell(stageOnlyCall).status === 0, 'staging-only probe resolves');
|
|
760
|
-
check(read(file).budget.counters.iterations === beforeStageOnly.budget.counters.iterations,
|
|
761
|
-
'git add does not double-count tracked or previously-untracked visible content');
|
|
762
|
-
|
|
763
|
-
const beforeCommitOnly = read(file);
|
|
764
|
-
const commitOnly = 'git commit -m observed-fixture';
|
|
765
|
-
const commitOnlyCall = beginShell(commitOnly);
|
|
766
|
-
check(commitOnlyCall.result.status === 0, 'pure commit is observed instead of pre-counted');
|
|
767
|
-
git(['-c', 'user.name=Harness Test', '-c', 'user.email=harness@example.invalid', 'commit', '-m', 'observed-fixture']);
|
|
768
|
-
check(persistShell(commitOnlyCall).status === 0, 'pure commit probe resolves');
|
|
769
|
-
check(read(file).budget.counters.iterations === beforeCommitOnly.budget.counters.iterations,
|
|
770
|
-
'pure commit of already-accounted content does not consume another code iteration');
|
|
771
|
-
|
|
772
|
-
const deleteTracked = 'node delete-tracked.js';
|
|
773
|
-
const deleteTrackedCall = beginShell(deleteTracked);
|
|
774
|
-
check(deleteTrackedCall.result.status === 0, 'unknown tracked-file deletion is admitted under observation');
|
|
775
|
-
fs.unlinkSync(path.join(repo, 'observed-untracked.txt'));
|
|
776
|
-
check(persistShell(deleteTrackedCall).status === 0, 'tracked-file deletion is observed');
|
|
777
|
-
check(read(file).budget.counters.iterations === ++observedIterations,
|
|
778
|
-
'tracked-file deletion consumes exactly one iteration');
|
|
779
|
-
|
|
780
|
-
const beforeStageDelete = read(file);
|
|
781
|
-
const stageDelete = 'git add -u observed-untracked.txt';
|
|
782
|
-
const stageDeleteCall = beginShell(stageDelete);
|
|
783
|
-
check(stageDeleteCall.result.status === 0, 'staging-only deletion is observed instead of pre-counted');
|
|
784
|
-
git(['add', '-u', 'observed-untracked.txt']);
|
|
785
|
-
check(persistShell(stageDeleteCall).status === 0, 'staging deletion probe resolves');
|
|
786
|
-
check(read(file).budget.counters.iterations === beforeStageDelete.budget.counters.iterations,
|
|
787
|
-
'git add -u does not double-count an already-observed deletion');
|
|
788
|
-
|
|
789
|
-
observedIterations = read(file).budget.counters.iterations;
|
|
790
|
-
for (let i = observedIterations; i < iterationLimit; i++) {
|
|
791
|
-
check(gate('Write', { file_path: path.join(repo, `small-${i}.js`) }, repo).status === 0, `confirmed mutation ${i + 1}/${iterationLimit} stays within budget`);
|
|
792
|
-
}
|
|
793
|
-
check(gate('Bash', { command: 'git status --short' }, repo).status === 0,
|
|
794
|
-
'proven read-only diagnosis remains allowed at the iteration limit');
|
|
795
|
-
check(gate('Bash', { command: 'git remote -v' }, repo).status === 2,
|
|
796
|
-
'untrusted shell command is blocked before execution once actual mutation budget is full');
|
|
797
|
-
check(gate('Write', { file_path: path.join(repo, 'small-overflow.js') }, repo).status === 2,
|
|
798
|
-
'blocked exhausted workflow still rejects direct mutation');
|
|
799
|
-
const iterativeExhausted = read(file);
|
|
800
|
-
check(iterativeExhausted.state === 'blocked' && iterativeExhausted.budget?.state === 'budget-exhausted' &&
|
|
801
|
-
iterativeExhausted.budget?.reasonCode === 'iteration-budget-exhausted', 'iterative exhaustion records blocked budget state');
|
|
802
|
-
const iterativeResetCommand = `node "${dispositionPath}" reset-budget --session-id ${sessionId} --evidence fixture-iteration-budget-reset`;
|
|
803
|
-
check(gate('Bash', { command: iterativeResetCommand }, repo).status === 0, 'iteration-exhausted workflow admits reset-budget through gate');
|
|
804
|
-
check(control('reset-budget', '--evidence', 'fixture-iteration-budget-reset').status === 0, 'iteration budget reset executes');
|
|
805
|
-
check(read(file).state === 'pending' && read(file).budget?.epoch === 1 && read(file).budget?.counters?.iterations === 0, 'iteration budget reset is audited and clears counters');
|
|
806
|
-
const iterativeStartCommand = `node "${dispositionPath}" start --session-id ${sessionId}`;
|
|
807
|
-
check(gate('Bash', { command: iterativeStartCommand }, repo).status === 0, 'iterative workflow restart is admitted through gate');
|
|
808
|
-
check(control('start').status === 0 && read(file).state === 'running', 'iteration-exhausted workflow resumes after reset');
|
|
809
|
-
check(stop().status === 2, 'direct/iterative route cannot complete unverified mutation');
|
|
810
|
-
check(stop({ stop_hook_active: true }).status === 0 && read(file).state === 'blocked', 'iterative retry reports incomplete state');
|
|
811
|
-
check(control('start').status === 0, 'blocked iterative route can resume without a Fable stage map');
|
|
812
|
-
// #153 + #159 ordering: when one shell command mutates and then verifies,
|
|
813
|
-
// observed mutation must land before state-persist records verification.
|
|
814
|
-
const mutateAndVerify = 'node mutate.js && npm test';
|
|
815
|
-
const mutateAndVerifyCall = beginShell(mutateAndVerify);
|
|
816
|
-
check(mutateAndVerifyCall.result.status === 0, 'combined mutation+verification command is admitted under observation');
|
|
817
|
-
fs.writeFileSync(path.join(repo, 'README.md'), 'fixture verified mutation\n');
|
|
818
|
-
check(persistShell(mutateAndVerifyCall, { stdout: 'tests passed' }).status === 0,
|
|
819
|
-
'single state-persist handler records mutation before verification');
|
|
820
|
-
check(stop().status === 0 && read(file).state === 'satisfied',
|
|
821
|
-
'verification without a host-reported numeric exit status resolves after same-command mutation');
|
|
822
|
-
node('hooks/scripts/state-persist.js', { ...payload, tool_name: 'Bash', tool_input: { command: 'npm test' }, tool_response: { exitCode: 0, stdout: 'passed' } });
|
|
823
|
-
check(stop().status === 0 && read(file).state === 'satisfied', 'observed iterative verification resolves completion');
|
|
824
|
-
console.log(`PASS: workflow lifecycle (${passed} assertions)`);
|
|
825
|
-
} finally {
|
|
826
|
-
if (!temp.startsWith(path.join(os.tmpdir(), 'harness-workflow-lifecycle-'))) throw new Error('unsafe fixture cleanup');
|
|
827
|
-
fs.rmSync(temp, { recursive: true, force: true });
|
|
828
|
-
}
|
|
829
|
-
if (!plugin) {
|
|
830
|
-
const packaged = spawnSync(process.execPath, [__filename, '--plugin'], { cwd: ROOT, env: process.env, encoding: 'utf8' });
|
|
831
|
-
assert.strictEqual(packaged.status, 0, packaged.stdout + packaged.stderr);
|
|
832
|
-
console.log('PASS: packaged OpenAI workflow lifecycle mirrors source (' + passed + ' assertions)');
|
|
833
|
-
}
|
|
3
|
+
const assert=require('assert'),fs=require('fs'),os=require('os'),path=require('path'),{spawnSync}=require('child_process');
|
|
4
|
+
const ROOT=path.resolve(__dirname,'..'),plugin=process.argv.includes('--plugin'),runtimeRoot=plugin?path.join(ROOT,'plugins/harness-everything'):ROOT;
|
|
5
|
+
const temp=fs.mkdtempSync(path.join(os.tmpdir(),'harness-workflow-guidance-')),repo=path.join(temp,'repo');fs.mkdirSync(repo);spawnSync('git',['init'],{cwd:repo});
|
|
6
|
+
const env={...process.env,HARNESS_STATE_HOME:path.join(temp,'state'),HARNESS_WORKSPACE_ROOT:repo};
|
|
7
|
+
process.env.HARNESS_STATE_HOME=env.HARNESS_STATE_HOME;process.env.HARNESS_WORKSPACE_ROOT=repo;
|
|
8
|
+
const state=require(path.join(runtimeRoot,'hooks/scripts/lib/harness-state')),sessionId='workflow-guidance',payload={session_id:sessionId,cwd:repo},sessionDir=state.getSessionDir(repo,sessionId),file=path.join(sessionDir,'workflow-run.json'),handoffFile=path.join(sessionDir,'handoff-state.json');
|
|
9
|
+
let passed=0;function check(v,m){assert.ok(v,m);passed++;console.log('PASS '+m);}function node(script,input,args=[]){const relative=plugin&&script.startsWith('harness-everything/')?'skills/'+script:script;return spawnSync(process.execPath,[path.join(runtimeRoot,relative),...args],{cwd:repo,env,input:input&&JSON.stringify(input),encoding:'utf8'});}function nodeRaw(script,input){return spawnSync(process.execPath,[path.join(runtimeRoot,script)],{cwd:repo,env,input,encoding:'utf8'});}function read(t){return JSON.parse(fs.readFileSync(t,'utf8'));}
|
|
10
|
+
try{
|
|
11
|
+
fs.writeFileSync(file,JSON.stringify({schemaVersion:1,sessionId,strategy:'iterative-single',tier:'tier2',state:'running',lastMutationAt:0},null,2));check(fs.existsSync(file),'workflow guidance fixture is created');
|
|
12
|
+
for(const script of ['hooks/scripts/workflow-gate.js','hooks/scripts/workflow-stop-gate.js']){const malformed=nodeRaw(script,'{"session_id":');check(malformed.status===0,script+' malformed payload fails open');}
|
|
13
|
+
const workflow=read(file);workflow.state='blocked';workflow.blockReason='iteration-budget-exhausted';workflow.budget={schemaVersion:1,state:'budget-exhausted',reasonCode:'iteration-budget-exhausted'};fs.writeFileSync(file,JSON.stringify(workflow));
|
|
14
|
+
const legacy=node('hooks/scripts/workflow-gate.js',{...payload,tool_name:'Write',tool_input:{file_path:path.join(repo,'x.js')}});check(legacy.status===0,'legacy exhausted state no longer blocks mutation');check(/Workflow Reminder/.test(legacy.stderr),'legacy state is a reminder');
|
|
15
|
+
const shell=node('hooks/scripts/workflow-gate.js',{...payload,tool_name:'Bash',tool_use_id:'toolu_advisory',tool_input:{command:'node maybe-mutates.js'}});check(shell.status===0,'unproven shell is not iteration-blocked');check(!fs.existsSync(path.join(sessionDir,'mutation-probes')),'no mutation-probe state is created');
|
|
16
|
+
const oldVerify=Date.now()-5000;fs.writeFileSync(handoffFile,JSON.stringify({lastEditAt:0,lastVerifyAt:oldVerify}));const failedMutationStarted=Date.now();const failedMutation=node('hooks/scripts/state-persist.js',{...payload,hook_event_name:'PostToolUseFailure',tool_name:'Bash',tool_input:{command:'echo changed > generated.txt'},tool_response:{exitCode:1,stderr:'failed after write'}});check(failedMutation.status===0,'failed shell mutation tracking is fail-open');const failedMutationState=read(handoffFile);check(failedMutationState.lastEditAt>=failedMutationStarted&&failedMutationState.lastVerifyAt===oldVerify,'failed-but-mutating shell advances edit freshness without fabricating verification');
|
|
17
|
+
fs.writeFileSync(handoffFile,JSON.stringify({lastEditAt:Date.now()}));const stop=node('hooks/scripts/workflow-stop-gate.js',{...payload});check(stop.status===0,'missing verification does not block Stop');check(/verification-after-edit-missing/.test(stop.stderr),'missing verification emits reminder');
|
|
18
|
+
const verify=node('hooks/scripts/state-persist.js',{...payload,hook_event_name:'PostToolUse',tool_name:'exec_command',tool_input:{command:'npm test'},tool_response:{exitCode:0,stdout:'ok'}});check(verify.status===0,'exec_command verification is fail-open');check(Number.isFinite(read(handoffFile).lastVerifyAt),'#193 exec_command records lastVerifyAt');
|
|
19
|
+
const blockedStop=node('hooks/scripts/workflow-stop-gate.js',{...payload});const blockedAfterVerify=read(file);check(blockedStop.status===0&&blockedAfterVerify.state==='blocked'&&blockedAfterVerify.blockReason==='iteration-budget-exhausted','resolved evidence never rewrites an explicit blocked workflow to satisfied');
|
|
20
|
+
console.log('PASS: workflow guidance lifecycle ('+passed+' assertions)');
|
|
21
|
+
}finally{fs.rmSync(temp,{recursive:true,force:true});}
|
|
22
|
+
if(!plugin){const packaged=spawnSync(process.execPath,[__filename,'--plugin'],{cwd:ROOT,env:process.env,encoding:'utf8'});assert.strictEqual(packaged.status,0,packaged.stdout+packaged.stderr);console.log('PASS: packaged OpenAI workflow guidance mirrors source');}
|