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.
Files changed (100) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +2 -2
  4. package/CHANGELOG.md +23 -0
  5. package/README.md +7 -7
  6. package/VERIFICATION.md +3 -3
  7. package/bin/cli.js +17 -0
  8. package/ci/invariant-routing.test.js +58 -158
  9. package/ci/mechanism-2a-rule-of-3.test.js +73 -138
  10. package/ci/mechanism-2ac-atomic-commit-check.test.js +2 -2
  11. package/ci/mechanism-2af-worktree-isolation.test.js +38 -196
  12. package/ci/mechanism-2ah-workflow-lifecycle.test.js +20 -831
  13. package/ci/mechanism-2ai-workspace-fingerprint-batch.test.js +3 -4
  14. package/ci/mechanism-2al-codex-hooks-compat.test.js +286 -0
  15. package/ci/mechanism-2b-boundary-guard.test.js +6 -16
  16. package/ci/mechanism-2e-scope-guard.test.js +8 -24
  17. package/ci/mechanism-2f-stop-gate.test.js +21 -75
  18. package/ci/mechanism-2n-opencode-plugin.test.js +13 -28
  19. package/ci/openai-plugin-package.test.js +8 -8
  20. package/ci/openai-plugin-runtime.test.js +2 -2
  21. package/ci/router-workflow-plan.test.js +6 -6
  22. package/ci/self-evolve-loop.test.js +4 -1
  23. package/ci/workflow-execution-contract.test.js +51 -129
  24. package/ci/workflow-plan-consumers-edge.test.js +1 -1
  25. package/ci/workflow-plan-consumers.test.js +6 -6
  26. package/docs/architecture.md +21 -21
  27. package/docs/enforcement-locks.md +41 -102
  28. package/docs/mechanism-first-skill-mesh.md +2 -2
  29. package/docs/philosophy.md +2 -2
  30. package/docs/platform-capabilities.md +19 -3
  31. package/docs/platform-compatibility.json +4 -4
  32. package/docs/routing.md +14 -14
  33. package/docs/workflow-runtime.md +80 -110
  34. package/evals/harness-everything/tasks/positive-tier1.yaml +1 -1
  35. package/evals/harness-everything/tasks/positive-tier2.yaml +1 -1
  36. package/evals/harness-everything/tasks/positive-tier3.yaml +1 -1
  37. package/evals/using-git-worktrees/tasks/positive-trigger.yaml +1 -1
  38. package/fable-mode/SKILL.md +1 -1
  39. package/fable-mode/execution-guardrails/SKILL.md +1 -1
  40. package/fable-mode/fable-haiku/SKILL.md +1 -1
  41. package/fable-mode/fable-opus/SKILL.md +1 -1
  42. package/fable-mode/fable-sonnet/SKILL.md +1 -1
  43. package/fable-mode/scripts/workflow-plan-consumer.js +4 -3
  44. package/harness-everything/SKILL.md +11 -11
  45. package/harness-everything/references/triage-and-tiers.md +11 -11
  46. package/harness-everything/scripts/kernel-router-core.js +5 -5
  47. package/harness-everything/scripts/kernel-router.js +15 -17
  48. package/harness-everything/scripts/router-contract-core.js +1 -1
  49. package/hooks/hooks.json +10 -23
  50. package/hooks/scripts/atomic-commit-check.js +1 -1
  51. package/hooks/scripts/boundary-guard.js +4 -4
  52. package/hooks/scripts/contract-test.js +8 -11
  53. package/hooks/scripts/depth-guard.js +2 -2
  54. package/hooks/scripts/lib/workflow-runtime.js +24 -402
  55. package/hooks/scripts/rule-of-3-tracker.js +28 -31
  56. package/hooks/scripts/rule-of-3.js +4 -28
  57. package/hooks/scripts/state-persist.js +36 -136
  58. package/hooks/scripts/stop-gate.js +15 -48
  59. package/hooks/scripts/subagent-scope-guard.js +8 -8
  60. package/hooks/scripts/workflow-disposition.js +2 -10
  61. package/hooks/scripts/workflow-gate.js +33 -84
  62. package/hooks/scripts/workflow-mutation-denied.js +3 -15
  63. package/hooks/scripts/workflow-stop-gate.js +23 -38
  64. package/opencode-plugin/README.md +9 -10
  65. package/opencode-plugin/index.mjs +14 -30
  66. package/opencode-plugin/plugin.json +1 -1
  67. package/package.json +4 -2
  68. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  69. package/plugins/harness-everything/hooks/hooks.json +3 -3
  70. package/plugins/harness-everything/hooks/scripts/atomic-commit-check.js +1 -1
  71. package/plugins/harness-everything/hooks/scripts/boundary-guard.js +4 -4
  72. package/plugins/harness-everything/hooks/scripts/contract-test.js +8 -11
  73. package/plugins/harness-everything/hooks/scripts/depth-guard.js +2 -2
  74. package/plugins/harness-everything/hooks/scripts/lib/workflow-runtime.js +24 -402
  75. package/plugins/harness-everything/hooks/scripts/rule-of-3-tracker.js +28 -31
  76. package/plugins/harness-everything/hooks/scripts/rule-of-3.js +4 -28
  77. package/plugins/harness-everything/hooks/scripts/state-persist.js +36 -136
  78. package/plugins/harness-everything/hooks/scripts/stop-gate.js +15 -48
  79. package/plugins/harness-everything/hooks/scripts/subagent-scope-guard.js +8 -8
  80. package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +2 -10
  81. package/plugins/harness-everything/hooks/scripts/workflow-gate.js +33 -84
  82. package/plugins/harness-everything/hooks/scripts/workflow-mutation-denied.js +3 -15
  83. package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +23 -38
  84. package/plugins/harness-everything/plugin.json +1 -1
  85. package/plugins/harness-everything/scripts/lib/advisory-text.js +16 -29
  86. package/plugins/harness-everything/skills/fable-mode/SKILL.md +1 -1
  87. package/plugins/harness-everything/skills/fable-mode/execution-guardrails/SKILL.md +1 -1
  88. package/plugins/harness-everything/skills/fable-mode/fable-haiku/SKILL.md +1 -1
  89. package/plugins/harness-everything/skills/fable-mode/fable-opus/SKILL.md +1 -1
  90. package/plugins/harness-everything/skills/fable-mode/fable-sonnet/SKILL.md +1 -1
  91. package/plugins/harness-everything/skills/fable-mode/scripts/workflow-plan-consumer.js +4 -3
  92. package/plugins/harness-everything/skills/harness-everything/SKILL.md +11 -11
  93. package/plugins/harness-everything/skills/harness-everything/references/triage-and-tiers.md +11 -11
  94. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router-core.js +5 -5
  95. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router.js +15 -17
  96. package/plugins/harness-everything/skills/harness-everything/scripts/router-contract-core.js +1 -1
  97. package/plugins/harness-everything/skills/using-git-worktrees/SKILL.md +5 -5
  98. package/scripts/codex-hooks-compat.js +521 -0
  99. package/scripts/lib/advisory-text.js +16 -29
  100. 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', 'reset-budget', 'escape', 'block']);
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
- const MUTATION_PROBE_KEY = /^[a-f0-9]{64}$/;
48
- const MUTATION_PROBE_WAIT = new Int32Array(new SharedArrayBuffer(4));
49
- const MUTATION_PROBE_DEFAULT_TIMEOUT_MS = 120000;
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) && limits.maxIterations > 0 ? limits.maxIterations : null,
304
- maxRevisionRounds: Number.isInteger(limits.maxRevisionRounds) && limits.maxRevisionRounds >= 0 ? limits.maxRevisionRounds : 0,
305
- maxReplans: Number.isInteger(limits.maxReplans) && limits.maxReplans >= 0 ? limits.maxReplans : 0,
306
- maxWorkers: Number.isInteger(limits.maxWorkers) && limits.maxWorkers > 0 ? limits.maxWorkers : 1,
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
- function ensureWorkflowBudget(workflow) {
311
- if (workflow.budget && workflow.budget.schemaVersion === 1) return workflow.budget;
312
- const now = new Date().toISOString();
313
- workflow.budget = {
314
- schemaVersion: 1,
315
- epoch: 0,
316
- state: 'active',
317
- limits: budgetLimits(workflow),
318
- counters: { iterations: 0, revisionRounds: 0, replans: 0 },
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
- const { workflow } = context;
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
- const { workflow } = context;
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 Gate] ${error.message}`); process.exitCode = 2; }
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
- // Context-aware thresholds: different failure types have different breaking points
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
- // NOTE (#166, follows #153): Claude Code PostToolUse success payloads
123
- // carry no numeric exit code. The lifecycle event is authoritative:
124
- // PostToolUse is success unless a numeric non-zero status contradicts it;
125
- // hosts without lifecycle event names keep the conservative stderr fallback.
126
- // A successful action after an accepted zoom-out is an objective recovery
127
- // boundary. Emit only IDs/hashes/category; never copy command/output text.
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
- // Only reset on a *confirmed* zero exit code, not merely "not a failure".
149
- if (state.count > 0 || state.zoomOutCycles > 0) {
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. Only a SECOND trip on the same failure
10
- // signature - proof that reflection alone couldn't crack it - hard-locks the
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
- if (!state || state.zoomOutResolved) return false;
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. If this same signature trips the ` +
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