harness-everything 0.15.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 +23 -0
- package/README.md +7 -7
- package/VERIFICATION.md +3 -3
- package/bin/cli.js +17 -0
- 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-2al-codex-hooks-compat.test.js +286 -0
- 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 +19 -3
- package/docs/platform-compatibility.json +4 -4
- 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 +4 -2
- 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/codex-hooks-compat.js +521 -0
- package/scripts/lib/advisory-text.js +16 -29
- package/using-git-worktrees/SKILL.md +5 -5
|
@@ -4,11 +4,10 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { getWorkspaceRoot, getSessionDir, getSessionId, getStateRoot } = require('./harness-state');
|
|
6
6
|
const { atomicWriteJson, readJson } = require('./fable-contracts');
|
|
7
|
-
const { workspaceFingerprint } = require('./workflow-isolation');
|
|
8
7
|
|
|
9
8
|
const OPEN_STATES = new Set(['pending', 'active', 'running', 'failed', 'blocked', 'escaped']);
|
|
10
9
|
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
11
|
-
const WORKFLOW_CONTROLLER_COMMANDS = new Set(['start', 'revision', '
|
|
10
|
+
const WORKFLOW_CONTROLLER_COMMANDS = new Set(['start', 'revision', 'escape', 'block']);
|
|
12
11
|
|
|
13
12
|
function activeWorkflowPlan(workflow) {
|
|
14
13
|
if (!workflow) return null;
|
|
@@ -44,412 +43,35 @@ function loadWorkflow(payload) {
|
|
|
44
43
|
|
|
45
44
|
function saveWorkflow(context) { atomicWriteJson(context.file, context.workflow); }
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const MUTATION_PROBE_RECLAIM_GRACE_MS = 5000;
|
|
51
|
-
const MUTATION_PROBE_MAX_TIMEOUT_MS = 30 * 60 * 1000;
|
|
52
|
-
|
|
53
|
-
function mutationProbeLeaseMs(timeoutMs) {
|
|
54
|
-
const parsed = Number(timeoutMs);
|
|
55
|
-
const executionMs = Number.isFinite(parsed) && parsed > 0
|
|
56
|
-
? Math.min(parsed, MUTATION_PROBE_MAX_TIMEOUT_MS)
|
|
57
|
-
: MUTATION_PROBE_DEFAULT_TIMEOUT_MS;
|
|
58
|
-
return executionMs + MUTATION_PROBE_RECLAIM_GRACE_MS;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function mutationProbeExpired(probe, now = Date.now()) {
|
|
62
|
-
const reclaimAfterAt = Number(probe?.reclaimAfterAt);
|
|
63
|
-
if (Number.isFinite(reclaimAfterAt)) return now >= reclaimAfterAt;
|
|
64
|
-
const observedAt = Number(probe?.observedAt);
|
|
65
|
-
return Number.isFinite(observedAt) &&
|
|
66
|
-
now - observedAt >= mutationProbeLeaseMs(null);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function mutationProbeDir(context) {
|
|
70
|
-
return path.join(context.sessionDir, 'mutation-probes');
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function mutationProbeFile(context, key) {
|
|
74
|
-
if (!MUTATION_PROBE_KEY.test(String(key || ''))) throw new Error('invalid mutation probe identity');
|
|
75
|
-
return path.join(mutationProbeDir(context), key + '.json');
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function withMutationProbeLock(context, callback) {
|
|
79
|
-
fs.mkdirSync(context.sessionDir, { recursive: true });
|
|
80
|
-
const lockDir = path.join(context.sessionDir, '.mutation-probes.lock');
|
|
81
|
-
let acquired = false;
|
|
82
|
-
for (let attempt = 0; attempt < 200; attempt++) {
|
|
83
|
-
try {
|
|
84
|
-
fs.mkdirSync(lockDir);
|
|
85
|
-
acquired = true;
|
|
86
|
-
break;
|
|
87
|
-
} catch (error) {
|
|
88
|
-
if (error.code !== 'EEXIST') throw error;
|
|
89
|
-
try {
|
|
90
|
-
const stat = fs.statSync(lockDir);
|
|
91
|
-
if (Date.now() - stat.mtimeMs > 30000) {
|
|
92
|
-
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
} catch (_) { /* retry below */ }
|
|
96
|
-
Atomics.wait(MUTATION_PROBE_WAIT, 0, 0, 10);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
if (!acquired) throw new Error('cannot acquire mutation probe lock');
|
|
100
|
-
try { return callback(); }
|
|
101
|
-
finally { fs.rmSync(lockDir, { recursive: true, force: true }); }
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function probeCountsIteration(probe) {
|
|
105
|
-
return Boolean(probe && (probe.countIteration === true ||
|
|
106
|
-
(probe.countIteration === undefined && probe.reserveIteration === true)));
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function mutationProbeReservations(context) {
|
|
110
|
-
const dir = mutationProbeDir(context);
|
|
111
|
-
let files = [];
|
|
112
|
-
try { files = fs.readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)); }
|
|
113
|
-
catch (_) { return 0; }
|
|
114
|
-
const now = Date.now();
|
|
115
|
-
let count = 0;
|
|
116
|
-
for (const file of files) {
|
|
117
|
-
const probe = readJson(path.join(dir, file));
|
|
118
|
-
if (probe?.schemaVersion !== 1 || probe.reserveIteration !== true) continue;
|
|
119
|
-
// A reservation remains active for the host/tool execution timeout plus
|
|
120
|
-
// a grace period. This prevents a genuinely in-flight command from being
|
|
121
|
-
// reclaimed merely because it ran longer than an arbitrary short delay.
|
|
122
|
-
if (!mutationProbeExpired(probe, now)) count++;
|
|
123
|
-
}
|
|
124
|
-
return count;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function refreshWorkflowForProbe(context) {
|
|
128
|
-
const latest = readJson(context.file);
|
|
129
|
-
if (!latest || latest.sessionId !== context.sessionId) throw new Error('cannot refresh workflow for mutation probe');
|
|
130
|
-
context.workflow = latest;
|
|
131
|
-
return latest;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function assertIterationCapacity(context, evidence) {
|
|
135
|
-
const { workflow } = context;
|
|
136
|
-
if (!workflow) throw budgetError('no workflow available for iteration capacity');
|
|
137
|
-
const budget = ensureWorkflowBudget(workflow);
|
|
138
|
-
if (budget.state !== 'active') throw budgetError(`workflow budget is ${budget.state}: ${budget.reasonCode || 'unavailable'}`);
|
|
139
|
-
const limit = budget.limits.maxIterations;
|
|
140
|
-
if (limit === null) return { applicable: false, value: budget.counters.iterations, reserved: 0 };
|
|
141
|
-
const reserved = mutationProbeReservations(context);
|
|
142
|
-
if (budget.counters.iterations + reserved >= limit) {
|
|
143
|
-
return exhaustBudget(context, 'iteration-budget-exhausted', evidence || 'untrusted-shell-capacity');
|
|
144
|
-
}
|
|
145
|
-
return { applicable: true, value: budget.counters.iterations, reserved, limit };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function reclaimStaleMutationProbes(context, keepKey) {
|
|
149
|
-
const dir = mutationProbeDir(context);
|
|
150
|
-
let files = [];
|
|
151
|
-
try { files = fs.readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)); }
|
|
152
|
-
catch (_) { return; }
|
|
153
|
-
const now = Date.now();
|
|
154
|
-
for (const name of files) {
|
|
155
|
-
const staleKey = name.slice(0, -'.json'.length);
|
|
156
|
-
if (staleKey === keepKey) continue;
|
|
157
|
-
const file = path.join(dir, name);
|
|
158
|
-
const probe = readJson(file);
|
|
159
|
-
if (!probe || probe.schemaVersion !== 1) continue;
|
|
160
|
-
if (!mutationProbeExpired(probe, now)) continue;
|
|
161
|
-
const countsIteration = probeCountsIteration(probe);
|
|
162
|
-
if (!probe.observationCwd) {
|
|
163
|
-
// Legacy probes predate stored observation roots and cannot be safely
|
|
164
|
-
// re-observed. Once outside the in-flight grace window they must not
|
|
165
|
-
// reserve capacity forever.
|
|
166
|
-
fs.unlinkSync(file);
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
169
|
-
let currentFingerprint;
|
|
170
|
-
try {
|
|
171
|
-
currentFingerprint = workspaceFingerprint(probe.observationCwd);
|
|
172
|
-
} catch (_) {
|
|
173
|
-
fs.unlinkSync(file);
|
|
174
|
-
context.workflow.lastMutationAt = now;
|
|
175
|
-
if (countsIteration && context.workflow.strategy === 'iterative-single') {
|
|
176
|
-
recordBudgetEvent(context, 'iteration', { evidence: 'shell:reclaimed-unobservable-workspace' });
|
|
177
|
-
} else {
|
|
178
|
-
saveWorkflow(context);
|
|
179
|
-
}
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
if (currentFingerprint !== probe.fingerprint) {
|
|
183
|
-
fs.unlinkSync(file);
|
|
184
|
-
context.workflow.lastMutationAt = now;
|
|
185
|
-
if (countsIteration && context.workflow.strategy === 'iterative-single') {
|
|
186
|
-
recordBudgetEvent(context, 'iteration', { evidence: 'shell:reclaimed-stale-workspace-mutation' });
|
|
187
|
-
} else {
|
|
188
|
-
saveWorkflow(context);
|
|
189
|
-
}
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
// No visible workspace effect after the grace window: this probe was
|
|
193
|
-
// orphaned by a denial/block/missing Post event. Discard it so it cannot
|
|
194
|
-
// reserve capacity or later attribute an unrelated mutation (#161).
|
|
195
|
-
fs.unlinkSync(file);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function registerMutationProbe(context, key, fingerprint, reserveIteration, observationCwd, options = {}) {
|
|
200
|
-
if (!MUTATION_PROBE_KEY.test(String(key || '')) || !MUTATION_PROBE_KEY.test(String(fingerprint || ''))) {
|
|
201
|
-
throw new Error('invalid mutation probe identity');
|
|
202
|
-
}
|
|
203
|
-
return withMutationProbeLock(context, () => {
|
|
204
|
-
refreshWorkflowForProbe(context);
|
|
205
|
-
reclaimStaleMutationProbes(context, key);
|
|
206
|
-
let activeReservation = Boolean(reserveIteration);
|
|
207
|
-
let atLimitAllowance = false;
|
|
208
|
-
if (activeReservation) {
|
|
209
|
-
const budget = ensureWorkflowBudget(context.workflow);
|
|
210
|
-
const limit = budget.limits.maxIterations;
|
|
211
|
-
const reserved = mutationProbeReservations(context);
|
|
212
|
-
if (options.allowAtLimit === true && budget.state === 'active' && limit !== null &&
|
|
213
|
-
budget.counters.iterations === limit && reserved === 0) {
|
|
214
|
-
// Verification-shaped commands get observation-only admission at the
|
|
215
|
-
// exact mutation limit. If they mutate, settlement still records the
|
|
216
|
-
// mutation and exhausts the budget after execution (#164).
|
|
217
|
-
activeReservation = false;
|
|
218
|
-
atLimitAllowance = true;
|
|
219
|
-
} else {
|
|
220
|
-
assertIterationCapacity(context, 'shell:untrusted');
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
const dir = mutationProbeDir(context);
|
|
224
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
225
|
-
const file = mutationProbeFile(context, key);
|
|
226
|
-
if (fs.existsSync(file)) throw new Error('mutation probe is already pending for this tool call');
|
|
227
|
-
const observedAt = Date.now();
|
|
228
|
-
const probe = {
|
|
229
|
-
schemaVersion: 1,
|
|
230
|
-
fingerprint,
|
|
231
|
-
reserveIteration: activeReservation,
|
|
232
|
-
countIteration: options.countIteration === undefined ? Boolean(reserveIteration) : Boolean(options.countIteration),
|
|
233
|
-
atLimitAllowance,
|
|
234
|
-
observationCwd: observationCwd ? path.resolve(observationCwd) : null,
|
|
235
|
-
observedAt,
|
|
236
|
-
reclaimAfterAt: observedAt + mutationProbeLeaseMs(options.timeoutMs),
|
|
237
|
-
};
|
|
238
|
-
atomicWriteJson(file, probe);
|
|
239
|
-
return probe;
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function peekMutationProbe(context, key) {
|
|
244
|
-
return readJson(mutationProbeFile(context, key));
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function settleMutationProbe(context, key, afterFingerprint, tool) {
|
|
248
|
-
if (!MUTATION_PROBE_KEY.test(String(afterFingerprint || ''))) throw new Error('invalid mutation probe fingerprint');
|
|
249
|
-
return withMutationProbeLock(context, () => {
|
|
250
|
-
const file = mutationProbeFile(context, key);
|
|
251
|
-
const probe = readJson(file);
|
|
252
|
-
if (!probe || probe.schemaVersion !== 1) return null;
|
|
253
|
-
refreshWorkflowForProbe(context);
|
|
254
|
-
fs.unlinkSync(file);
|
|
255
|
-
const changed = afterFingerprint !== probe.fingerprint;
|
|
256
|
-
if (changed) {
|
|
257
|
-
context.workflow.lastMutationAt = Date.now();
|
|
258
|
-
if (probeCountsIteration(probe) && context.workflow.strategy === 'iterative-single') {
|
|
259
|
-
recordBudgetEvent(context, 'iteration', { evidence: String(tool || 'shell') + ':observed-workspace-mutation' });
|
|
260
|
-
} else {
|
|
261
|
-
saveWorkflow(context);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
return { changed, probe };
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function settleMutationProbeConservative(context, key, tool, evidence = 'unobservable-workspace') {
|
|
269
|
-
return withMutationProbeLock(context, () => {
|
|
270
|
-
const file = mutationProbeFile(context, key);
|
|
271
|
-
const probe = readJson(file);
|
|
272
|
-
if (!probe || probe.schemaVersion !== 1) return null;
|
|
273
|
-
refreshWorkflowForProbe(context);
|
|
274
|
-
fs.unlinkSync(file);
|
|
275
|
-
context.workflow.lastMutationAt = Date.now();
|
|
276
|
-
if (probeCountsIteration(probe) && context.workflow.strategy === 'iterative-single') {
|
|
277
|
-
recordBudgetEvent(context, 'iteration', { evidence: String(tool || 'shell') + ':' + evidence });
|
|
278
|
-
} else {
|
|
279
|
-
saveWorkflow(context);
|
|
280
|
-
}
|
|
281
|
-
return { changed: true, probe, conservative: true };
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function discardMutationProbe(context, key) {
|
|
286
|
-
return withMutationProbeLock(context, () => {
|
|
287
|
-
const file = mutationProbeFile(context, key);
|
|
288
|
-
if (!fs.existsSync(file)) return false;
|
|
289
|
-
fs.unlinkSync(file);
|
|
290
|
-
return true;
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function clearMutationProbes(context) {
|
|
295
|
-
return withMutationProbeLock(context, () => {
|
|
296
|
-
fs.rmSync(mutationProbeDir(context), { recursive: true, force: true });
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
|
|
46
|
+
// Numeric workflow limits are planning hints only. Exact runtime budget/probe
|
|
47
|
+
// enforcement was retired in #190 because it introduced locks, shared-state
|
|
48
|
+
// races, parser exceptions, and recovery states disproportionate to its value.
|
|
300
49
|
function budgetLimits(workflow) {
|
|
301
50
|
const limits = workflow?.workflowPlan?.limits || {};
|
|
302
51
|
return {
|
|
303
|
-
maxIterations: Number.isInteger(limits.maxIterations)
|
|
304
|
-
maxRevisionRounds: Number.isInteger(limits.maxRevisionRounds)
|
|
305
|
-
maxReplans: Number.isInteger(limits.maxReplans)
|
|
306
|
-
maxWorkers: Number.isInteger(limits.maxWorkers)
|
|
52
|
+
maxIterations: Number.isInteger(limits.maxIterations) ? limits.maxIterations : null,
|
|
53
|
+
maxRevisionRounds: Number.isInteger(limits.maxRevisionRounds) ? limits.maxRevisionRounds : null,
|
|
54
|
+
maxReplans: Number.isInteger(limits.maxReplans) ? limits.maxReplans : null,
|
|
55
|
+
maxWorkers: Number.isInteger(limits.maxWorkers) ? limits.maxWorkers : null,
|
|
307
56
|
};
|
|
308
57
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
activeWorkers: {},
|
|
320
|
-
reasonCode: null,
|
|
321
|
-
exhaustedAt: null,
|
|
322
|
-
resets: [],
|
|
323
|
-
events: [],
|
|
324
|
-
createdAt: now,
|
|
325
|
-
updatedAt: now,
|
|
326
|
-
};
|
|
327
|
-
return workflow.budget;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function syncBudgetToRun(context) {
|
|
331
|
-
const match = matchingRun(context);
|
|
332
|
-
if (!match || !context.workflow?.budget) return;
|
|
333
|
-
const updated = {
|
|
334
|
-
...match.run,
|
|
335
|
-
budget: JSON.parse(JSON.stringify(context.workflow.budget)),
|
|
336
|
-
};
|
|
337
|
-
atomicWriteJson(path.join(match.runRoot, 'run.json'), updated);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function persistBudget(context) {
|
|
341
|
-
saveWorkflow(context);
|
|
342
|
-
syncBudgetToRun(context);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function budgetError(message) {
|
|
346
|
-
const error = new Error(message);
|
|
347
|
-
error.code = 'HARNESS_WORKFLOW_BUDGET';
|
|
348
|
-
return error;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function appendBudgetEvent(budget, event) {
|
|
352
|
-
budget.events = [...(budget.events || []), event].slice(-64);
|
|
353
|
-
budget.updatedAt = event.observedAt;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function exhaustBudget(context, reasonCode, evidence) {
|
|
357
|
-
const budget = ensureWorkflowBudget(context.workflow);
|
|
358
|
-
const now = new Date().toISOString();
|
|
359
|
-
budget.state = 'budget-exhausted';
|
|
360
|
-
budget.reasonCode = reasonCode;
|
|
361
|
-
budget.exhaustedAt = now;
|
|
362
|
-
appendBudgetEvent(budget, { type: 'budget-exhausted', reasonCode, evidence: evidence || null, observedAt: now });
|
|
363
|
-
context.workflow.state = 'blocked';
|
|
364
|
-
context.workflow.blockReason = reasonCode;
|
|
365
|
-
persistBudget(context);
|
|
366
|
-
throw budgetError(`budget-exhausted: ${reasonCode}`);
|
|
367
|
-
}
|
|
368
|
-
|
|
58
|
+
function ensureWorkflowBudget(workflow) { return { schemaVersion: 2, state: 'advisory', limits: budgetLimits(workflow) }; }
|
|
59
|
+
// Compatibility stubs: no files, reservations, counters, locks, or blocks.
|
|
60
|
+
function mutationProbeReservations() { return 0; }
|
|
61
|
+
function assertIterationCapacity() { return { applicable: false, advisory: true, reserved: 0 }; }
|
|
62
|
+
function registerMutationProbe() { return null; }
|
|
63
|
+
function peekMutationProbe() { return null; }
|
|
64
|
+
function settleMutationProbe() { return null; }
|
|
65
|
+
function settleMutationProbeConservative() { return null; }
|
|
66
|
+
function discardMutationProbe() { return false; }
|
|
67
|
+
function clearMutationProbes() { return false; }
|
|
369
68
|
function recordBudgetEvent(context, type, options = {}) {
|
|
370
|
-
|
|
371
|
-
if (!workflow) throw budgetError('no workflow available for budget accounting');
|
|
372
|
-
const budget = ensureWorkflowBudget(workflow);
|
|
373
|
-
if (budget.state !== 'active') throw budgetError(`workflow budget is ${budget.state}: ${budget.reasonCode || 'unavailable'}`);
|
|
374
|
-
const now = new Date().toISOString();
|
|
375
|
-
const evidence = options.evidence || null;
|
|
376
|
-
|
|
377
|
-
const consume = (counter, limitName, reasonCode) => {
|
|
378
|
-
const limit = budget.limits[limitName];
|
|
379
|
-
if (limit === null) return { applicable: false, value: budget.counters[counter] };
|
|
380
|
-
const reserved = type === 'iteration' ? mutationProbeReservations(context) : 0;
|
|
381
|
-
if (budget.counters[counter] + reserved >= limit) return exhaustBudget(context, reasonCode, evidence);
|
|
382
|
-
budget.counters[counter]++;
|
|
383
|
-
appendBudgetEvent(budget, { type, counter, value: budget.counters[counter], limit, evidence, observedAt: now });
|
|
384
|
-
persistBudget(context);
|
|
385
|
-
return { applicable: true, value: budget.counters[counter], limit };
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
if (type === 'iteration') return consume('iterations', 'maxIterations', 'iteration-budget-exhausted');
|
|
389
|
-
if (type === 'revision') return consume('revisionRounds', 'maxRevisionRounds', 'revision-budget-exhausted');
|
|
390
|
-
if (type === 'replan') return consume('replans', 'maxReplans', 'replan-budget-exhausted');
|
|
391
|
-
|
|
392
|
-
if (type === 'worker-acquire') {
|
|
393
|
-
const workerId = String(options.workerId || '').trim();
|
|
394
|
-
if (!SAFE_ID.test(workerId)) throw budgetError('worker-acquire requires a stable safe worker id');
|
|
395
|
-
if (budget.activeWorkers[workerId]) return { active: Object.keys(budget.activeWorkers).length, idempotent: true };
|
|
396
|
-
const active = Object.keys(budget.activeWorkers).length;
|
|
397
|
-
if (active >= budget.limits.maxWorkers) return exhaustBudget(context, 'worker-budget-exhausted', evidence || workerId);
|
|
398
|
-
budget.activeWorkers[workerId] = { acquiredAt: now, evidence };
|
|
399
|
-
appendBudgetEvent(budget, { type, workerId, active: active + 1, limit: budget.limits.maxWorkers, evidence, observedAt: now });
|
|
400
|
-
persistBudget(context);
|
|
401
|
-
return { active: active + 1, limit: budget.limits.maxWorkers };
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
if (type === 'worker-release') {
|
|
405
|
-
const workerId = String(options.workerId || '').trim();
|
|
406
|
-
if (!SAFE_ID.test(workerId)) throw budgetError('worker-release requires a stable safe worker id');
|
|
407
|
-
const existed = Boolean(budget.activeWorkers[workerId]);
|
|
408
|
-
delete budget.activeWorkers[workerId];
|
|
409
|
-
appendBudgetEvent(budget, { type, workerId, existed, active: Object.keys(budget.activeWorkers).length, evidence, observedAt: now });
|
|
410
|
-
persistBudget(context);
|
|
411
|
-
return { active: Object.keys(budget.activeWorkers).length, existed };
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
throw budgetError(`unknown workflow budget event: ${type}`);
|
|
69
|
+
return { advisory: true, type, limits: budgetLimits(context && context.workflow), evidence: options.evidence || null };
|
|
415
70
|
}
|
|
416
|
-
|
|
417
71
|
function resetWorkflowBudget(context, evidence) {
|
|
418
|
-
|
|
419
|
-
if (!workflow) throw budgetError('no workflow available for budget reset');
|
|
420
|
-
const previous = ensureWorkflowBudget(workflow);
|
|
421
|
-
if (previous.state !== 'budget-exhausted' || workflow.state !== 'blocked') {
|
|
422
|
-
throw budgetError('budget reset is allowed only after explicit budget exhaustion');
|
|
423
|
-
}
|
|
424
|
-
if (!String(evidence || '').trim()) throw budgetError('budget reset requires audit evidence');
|
|
425
|
-
const now = new Date().toISOString();
|
|
426
|
-
const resets = [...(previous.resets || []), {
|
|
427
|
-
epoch: previous.epoch,
|
|
428
|
-
reasonCode: previous.reasonCode,
|
|
429
|
-
counters: previous.counters,
|
|
430
|
-
evidence: String(evidence).trim(),
|
|
431
|
-
resetAt: now,
|
|
432
|
-
}].slice(-16);
|
|
433
|
-
clearMutationProbes(context);
|
|
434
|
-
workflow.budget = {
|
|
435
|
-
schemaVersion: 1,
|
|
436
|
-
epoch: previous.epoch + 1,
|
|
437
|
-
state: 'active',
|
|
438
|
-
limits: budgetLimits(workflow),
|
|
439
|
-
counters: { iterations: 0, revisionRounds: 0, replans: 0 },
|
|
440
|
-
activeWorkers: {},
|
|
441
|
-
reasonCode: null,
|
|
442
|
-
exhaustedAt: null,
|
|
443
|
-
resets,
|
|
444
|
-
events: [{ type: 'budget-reset', evidence: String(evidence).trim(), observedAt: now }],
|
|
445
|
-
createdAt: previous.createdAt || now,
|
|
446
|
-
updatedAt: now,
|
|
447
|
-
};
|
|
448
|
-
workflow.state = 'pending';
|
|
449
|
-
delete workflow.blockReason;
|
|
450
|
-
persistBudget(context);
|
|
451
|
-
return workflow.budget;
|
|
72
|
+
return { retired: true, state: 'advisory', limits: budgetLimits(context && context.workflow), evidence: evidence || null };
|
|
452
73
|
}
|
|
74
|
+
function syncBudgetToRun() { return false; }
|
|
453
75
|
|
|
454
76
|
function matchingRun(context) {
|
|
455
77
|
const { workflow, root, sessionId } = context;
|
|
@@ -463,7 +85,7 @@ function matchingRun(context) {
|
|
|
463
85
|
return { runRoot, run };
|
|
464
86
|
}
|
|
465
87
|
|
|
466
|
-
function unresolvedStages(match, workflow) {
|
|
88
|
+
function unresolvedStages(match, workflow, lastEditAt = 0) {
|
|
467
89
|
const unresolved = [];
|
|
468
90
|
const stages = [];
|
|
469
91
|
for (const stageId of match.run.stageIds) {
|
|
@@ -491,7 +113,7 @@ function unresolvedStages(match, workflow) {
|
|
|
491
113
|
const workers = stages.filter(stage => stage.agent !== 'fable-verifier').map(stage => stage.workerId).filter(Boolean);
|
|
492
114
|
const latestCheck = Math.max(0, ...stages.filter(stage => stage.agent !== 'fable-verifier').map(stage => Date.parse(stage.verifiedAt) || 0));
|
|
493
115
|
if (!verifiers.some(stage => stage.workerId && !workers.includes(stage.workerId) && stage.status === 'pass' &&
|
|
494
|
-
Date.parse(stage.verifiedAt) >= Math.max(workflow.lastMutationAt || 0, latestCheck) &&
|
|
116
|
+
Date.parse(stage.verifiedAt) >= Math.max(workflow.lastMutationAt || 0, lastEditAt || 0, latestCheck) &&
|
|
495
117
|
match.run.stageIds.filter(id => id !== stage.stageId).every(id => stage.dependsOn?.includes(id)))) {
|
|
496
118
|
unresolved.push('independent-verifier-missing');
|
|
497
119
|
}
|
|
@@ -507,7 +129,7 @@ function readHookInput(decide) {
|
|
|
507
129
|
finished = true;
|
|
508
130
|
clearTimeout(timeout);
|
|
509
131
|
try { decide(input.trim() ? JSON.parse(input) : null); }
|
|
510
|
-
catch (error) { console.error(`[Workflow
|
|
132
|
+
catch (error) { console.error(`[Workflow Reminder] Ignoring malformed workflow hook input: ${error.message}`); }
|
|
511
133
|
};
|
|
512
134
|
const timeout = setTimeout(finish, 500);
|
|
513
135
|
process.stdin.setEncoding('utf8');
|
|
@@ -24,6 +24,11 @@ process.stdin.on('end', () => {
|
|
|
24
24
|
const failureText = typeof payload.error === 'string' ? payload.error : '';
|
|
25
25
|
const rawExitCode = toolResponse.exitCode ?? toolResponse.exit_code ?? payload.exitCode ?? payload.exit_code;
|
|
26
26
|
const exitCode = typeof rawExitCode === 'number' ? rawExitCode : undefined;
|
|
27
|
+
const toolName = payload.tool_name || payload.tool || 'command';
|
|
28
|
+
const toolInput = payload.tool_input || {};
|
|
29
|
+
const filePath = toolInput.file_path || toolInput.filePath || '';
|
|
30
|
+
const command = toolInput.command || '';
|
|
31
|
+
const operationHash = crypto.createHash('md5').update(`${toolName}:${filePath}:${command}`).digest('hex');
|
|
27
32
|
|
|
28
33
|
const explicitFailure = exitCode !== undefined && exitCode !== 0;
|
|
29
34
|
const stderrSignal = typeof stderr === 'string' && stderr.trim().length > 0;
|
|
@@ -37,7 +42,7 @@ process.stdin.on('end', () => {
|
|
|
37
42
|
const root = getWorkspaceRoot(payload);
|
|
38
43
|
const stateFile = path.join(getSessionDir(root, payload.session_id || payload.sessionId), 'rule-of-3-state.json');
|
|
39
44
|
|
|
40
|
-
let state = { count: 0, lastHash: null, zoomOutResolved: false, zoomOutCycles: 0, lastFailureAt: 0 };
|
|
45
|
+
let state = { count: 0, lastHash: null, lastOperationHash: null, zoomOutResolved: false, zoomOutCycles: 0, lastFailureAt: 0 };
|
|
41
46
|
if (fs.existsSync(stateFile)) {
|
|
42
47
|
state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
43
48
|
}
|
|
@@ -45,9 +50,6 @@ process.stdin.on('end', () => {
|
|
|
45
50
|
if (isFailure && errorText) {
|
|
46
51
|
// Enhanced failure signature detection with context-aware categorization
|
|
47
52
|
// Hash includes: error message, file path, command attempted, and failure category
|
|
48
|
-
const filePath = payload.tool_input?.file_path || payload.tool_input?.filePath || '';
|
|
49
|
-
const command = payload.tool_input?.command || '';
|
|
50
|
-
|
|
51
53
|
// Categorize failure type for context-aware thresholds.
|
|
52
54
|
// NOTE (#167): match anchored diagnostic phrases, not bare substrings.
|
|
53
55
|
// A bare `denied`/`timeout` substring matches file names such as
|
|
@@ -79,22 +81,7 @@ process.stdin.on('end', () => {
|
|
|
79
81
|
const contextString = `${category}:${filePath}:${command}:${normalized.slice(-200)}`;
|
|
80
82
|
const hash = crypto.createHash('md5').update(contextString).digest('hex');
|
|
81
83
|
|
|
82
|
-
//
|
|
83
|
-
let threshold = 3; // Default: 3 strikes
|
|
84
|
-
if (category === 'syntax') {
|
|
85
|
-
threshold = 3; // Same syntax error 3 times = stop
|
|
86
|
-
} else if (category === 'environment') {
|
|
87
|
-
threshold = 4; // Environment issues may need more exploration
|
|
88
|
-
} else if (category === 'test') {
|
|
89
|
-
threshold = 3; // Same test failure 3 times = stop
|
|
90
|
-
} else if (category === 'timeout') {
|
|
91
|
-
threshold = 2; // Timeouts are expensive, fail faster
|
|
92
|
-
} else if (category === 'permission') {
|
|
93
|
-
threshold = 2; // Permission issues rarely resolve by retrying
|
|
94
|
-
} else if (category === 'dependency') {
|
|
95
|
-
threshold = 4; // Dependency issues may need different approaches
|
|
96
|
-
}
|
|
97
|
-
|
|
84
|
+
// One deliberately simple policy: three repeats of the same signature trigger zoom-out.
|
|
98
85
|
if (state.lastHash === hash) {
|
|
99
86
|
state.count += 1;
|
|
100
87
|
state.category = category; // Track failure category for smarter decisions
|
|
@@ -105,9 +92,7 @@ process.stdin.on('end', () => {
|
|
|
105
92
|
// New signature = new problem: the zoom-out cycle budget starts over.
|
|
106
93
|
state.zoomOutCycles = 0;
|
|
107
94
|
}
|
|
108
|
-
|
|
109
|
-
// Store threshold for this failure type
|
|
110
|
-
state.threshold = threshold;
|
|
95
|
+
state.lastOperationHash = operationHash;
|
|
111
96
|
|
|
112
97
|
// Any fresh failure re-arms the breaker. Without this, a signature that
|
|
113
98
|
// failed again after a success (or after a zoom-out release) would keep
|
|
@@ -119,12 +104,12 @@ process.stdin.on('end', () => {
|
|
|
119
104
|
|
|
120
105
|
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
|
|
121
106
|
} else if (!isFailure) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
107
|
+
let stateChanged = false;
|
|
108
|
+
|
|
109
|
+
// The first confirmed success after an accepted zoom-out is useful
|
|
110
|
+
// learning evidence even when it is not the same command that failed.
|
|
111
|
+
// Consume only the recovery-cycle marker here; do not erase the
|
|
112
|
+
// repeated-failure signature or count for an unrelated operation.
|
|
128
113
|
if ((state.zoomOutCycles || 0) > 0 && state.lastHash) {
|
|
129
114
|
const toolEventId = payload.tool_use_id || payload.toolUseId || `success-${Date.now()}`;
|
|
130
115
|
try {
|
|
@@ -144,12 +129,24 @@ process.stdin.on('end', () => {
|
|
|
144
129
|
} catch (_) {
|
|
145
130
|
// Learning capture is additive and must never break recovery tracking.
|
|
146
131
|
}
|
|
132
|
+
state.zoomOutCycles = 0;
|
|
133
|
+
stateChanged = true;
|
|
147
134
|
}
|
|
148
|
-
|
|
149
|
-
|
|
135
|
+
|
|
136
|
+
// Only a success for the same operation resolves its tracked signature.
|
|
137
|
+
if (state.lastOperationHash && state.lastOperationHash === operationHash &&
|
|
138
|
+
(state.count > 0 || state.lastHash)) {
|
|
150
139
|
state.count = 0;
|
|
140
|
+
state.lastHash = null;
|
|
141
|
+
state.lastOperationHash = null;
|
|
151
142
|
state.zoomOutResolved = true;
|
|
152
143
|
state.zoomOutCycles = 0;
|
|
144
|
+
state.lastFailureAt = 0;
|
|
145
|
+
delete state.category;
|
|
146
|
+
stateChanged = true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (stateChanged) {
|
|
153
150
|
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
|
|
154
151
|
}
|
|
155
152
|
}
|
|
@@ -6,9 +6,8 @@
|
|
|
6
6
|
// breaker locks mutating tools and demands a zoom-out fact-check first
|
|
7
7
|
// (read-only tools stay available). A valid reflection report - written to
|
|
8
8
|
// the session's zoom-out-report.md (path given in the block message below) -
|
|
9
|
-
// releases the breaker again.
|
|
10
|
-
//
|
|
11
|
-
// session until the Human Partner decides.
|
|
9
|
+
// releases the breaker again. A later set of three matching failures triggers
|
|
10
|
+
// another zoom-out; there is no persistent second-stage hard lock.
|
|
12
11
|
//
|
|
13
12
|
// State is scoped per Claude Code session_id (see lib/harness-state.js) so
|
|
14
13
|
// two sessions open on the same repo never share a trip count. The common
|
|
@@ -33,11 +32,7 @@ function readState(stateFile) {
|
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
function isTripped(state) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// Use context-aware thresholds if available, otherwise default to 3
|
|
39
|
-
const threshold = state.threshold || 3;
|
|
40
|
-
return state.count >= threshold;
|
|
35
|
+
return Boolean(state && !state.zoomOutResolved && state.count >= 3);
|
|
41
36
|
}
|
|
42
37
|
|
|
43
38
|
function anySessionTripped(root) {
|
|
@@ -102,21 +97,6 @@ function blockForReflection(state, reportFile) {
|
|
|
102
97
|
process.exit(2);
|
|
103
98
|
}
|
|
104
99
|
|
|
105
|
-
function blockForHumanDecision(state) {
|
|
106
|
-
console.error(`[CRITICAL] RULE OF 3 CIRCUIT BREAKER TRIGGERED! (repeat trip - hard lock)`);
|
|
107
|
-
console.error(`Error Signature: ${state.lastHash}`);
|
|
108
|
-
console.error(`\nYou already completed a zoom-out reflection for this same failure signature`);
|
|
109
|
-
console.error(`and the fresh diagnosis failed too. This is now past reflect-and-retry:`);
|
|
110
|
-
console.error(`STOP and hand the decision to your Human Partner.`);
|
|
111
|
-
console.error(`\nPresent a DECISION REQUEST, not a plea: the goal, the verified facts, every`);
|
|
112
|
-
console.error(`path already falsified (including the post-reflection diagnosis), 2-3`);
|
|
113
|
-
console.error(`concrete options with trade-offs, and your recommendation.`);
|
|
114
|
-
console.error(`\nThis lock persists until the Human Partner clears it by running`);
|
|
115
|
-
console.error(`"npm run harness:reset" in their own terminal, or by starting a new`);
|
|
116
|
-
console.error(`session / running /clear (which resets it automatically on SessionStart).`);
|
|
117
|
-
process.exit(2);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
100
|
function main(payload) {
|
|
121
101
|
try {
|
|
122
102
|
const root = getWorkspaceRoot(payload);
|
|
@@ -142,15 +122,11 @@ function main(payload) {
|
|
|
142
122
|
`[Rule of 3] Zoom-out report accepted - breaker released. Follow your ` +
|
|
143
123
|
`report's Decision: RESUME means one fresh run at the NEW diagnosis; ` +
|
|
144
124
|
`ESCALATE means present the decision request and WAIT for the Human ` +
|
|
145
|
-
`Partner before mutating anything.
|
|
146
|
-
`breaker again, it hard-locks for a human decision.`
|
|
125
|
+
`Partner before mutating anything. A later set of three matching failures will trigger another zoom-out.`
|
|
147
126
|
);
|
|
148
127
|
process.exit(0);
|
|
149
128
|
}
|
|
150
129
|
|
|
151
|
-
if ((state.zoomOutCycles || 0) >= 1) {
|
|
152
|
-
blockForHumanDecision(state);
|
|
153
|
-
}
|
|
154
130
|
blockForReflection(state, reportFile);
|
|
155
131
|
} catch (err) {
|
|
156
132
|
// Fail open if state tracking breaks
|