harness-everything 0.12.0 → 0.12.2

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.
@@ -10,7 +10,7 @@
10
10
  "name": "harness-everything",
11
11
  "source": "./",
12
12
  "description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
13
- "version": "0.12.0",
13
+ "version": "0.12.2",
14
14
  "author": {
15
15
  "name": "Miya Daniel"
16
16
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-everything",
3
3
  "description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
4
- "version": "0.12.0",
4
+ "version": "0.12.2",
5
5
  "author": {
6
6
  "name": "Miya Daniel"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## [0.12.2](https://github.com/dyphn1/Harness-everything/compare/v0.12.1...v0.12.2) (2026-09-19)
6
+
7
+ ### Bug Fixes
8
+
9
+ * **workflow:** admit observation-only verification at limit ([08b859e](https://github.com/dyphn1/Harness-everything/commit/08b859ef27c04a07d9a0a6faeeed52f3da986378))
10
+ * **workflow:** admit observation-only verification at limit ([8924a3c](https://github.com/dyphn1/Harness-everything/commit/8924a3cc80455592accbe81e3f2df0f4479a2d8b))
11
+ * **workflow:** allow verification probe at iteration limit ([7ce511e](https://github.com/dyphn1/Harness-everything/commit/7ce511e29ca368371d996f4cbeff02298d97434c))
12
+ * **workflow:** allow verification probe at iteration limit ([07e6ed0](https://github.com/dyphn1/Harness-everything/commit/07e6ed0b369e38f274ac099af590b12373b1ad1c))
13
+
14
+ ## [0.12.1](https://github.com/dyphn1/Harness-everything/compare/v0.12.0...v0.12.1) (2026-09-19)
15
+
16
+ ### Bug Fixes
17
+
18
+ * **workflow:** bind probe lease to tool timeout ([bb8c598](https://github.com/dyphn1/Harness-everything/commit/bb8c598792530f7a06f8514c51dc9905fc78935c))
19
+ * **workflow:** bind probe lease to tool timeout ([025d6eb](https://github.com/dyphn1/Harness-everything/commit/025d6eb899be92598836ddc0c25c2eced8d4d190))
20
+ * **workflow:** discard unchanged stale probe orphans ([aa8d238](https://github.com/dyphn1/Harness-everything/commit/aa8d238199285b7338edf6b87495cd7454a440ac))
21
+ * **workflow:** discard unchanged stale probe orphans ([49fa840](https://github.com/dyphn1/Harness-everything/commit/49fa8409eb337a8acb5f6149323298b0b82027e8))
22
+ * **workflow:** keep active mutation probes leased ([c62831f](https://github.com/dyphn1/Harness-everything/commit/c62831f64d6b5e3991b4cde34a0bf263c7cab2e7))
23
+ * **workflow:** keep active mutation probes leased ([23f8b3d](https://github.com/dyphn1/Harness-everything/commit/23f8b3d646747f7e538061490a1a9bbf05fb8c39))
24
+ * **workflow:** key shell probes by stable tool-use identity ([4d24ca6](https://github.com/dyphn1/Harness-everything/commit/4d24ca6f538f1c1ca0d3165c1b8adce930a8d9d4))
25
+ * **workflow:** key shell probes by stable tool-use identity ([56c24b4](https://github.com/dyphn1/Harness-everything/commit/56c24b49748d9bd74cc3fe634cce291a691baa15))
26
+ * **workflow:** persist probe observation cwd ([51c5894](https://github.com/dyphn1/Harness-everything/commit/51c58945dbed392424e4690f6be78ad68db8ecbf))
27
+ * **workflow:** persist probe observation cwd ([9e361f1](https://github.com/dyphn1/Harness-everything/commit/9e361f1233f08f2f74c376c38e5d34dab017cbae))
28
+ * **workflow:** reclaim stale mutation-probe reservations ([fe613ec](https://github.com/dyphn1/Harness-everything/commit/fe613ec7cdea09e8b535e60f8a1a6468f649006d))
29
+ * **workflow:** reclaim stale mutation-probe reservations ([62f44d0](https://github.com/dyphn1/Harness-everything/commit/62f44d0f455ea3baf14b864aa33b1c868c6ef945))
30
+ * **workflow:** settle probes from their pre-tool workspace ([e16f7da](https://github.com/dyphn1/Harness-everything/commit/e16f7da4a2290afb1603e9c82b775b4fc6872acb))
31
+ * **workflow:** settle probes from their pre-tool workspace ([a7cce6c](https://github.com/dyphn1/Harness-everything/commit/a7cce6c5d7369bd40565978534c09687c8101408))
32
+ * **workflow:** use conservative default probe timeout ([e4d4e0f](https://github.com/dyphn1/Harness-everything/commit/e4d4e0f0f31d9feed068eb98abcba961c4ee078a))
33
+ * **workflow:** use conservative default probe timeout ([1393450](https://github.com/dyphn1/Harness-everything/commit/13934502a715b1cfa3b8caa76a300b38f0059afd))
34
+
5
35
  ## [0.12.0](https://github.com/dyphn1/Harness-everything/compare/v0.11.7...v0.12.0) (2026-09-19)
6
36
 
7
37
  ### Features
package/README.md CHANGED
@@ -255,6 +255,7 @@ For a deep dive into individual modules and the underlying philosophy, explore o
255
255
 
256
256
  * [Harness Philosophy](docs/philosophy.md): The core behavior-first, intervention-only design.
257
257
  * [Harness Architecture](docs/architecture.md): Lifecycle hooks, security model, and data locality.
258
+ * [Enforcement and Lock Diagnostic Flows](docs/enforcement-locks.md): Identify which gate blocked progress, why it fired, and how to recover.
258
259
  * [Platform Capability Matrix](docs/platform-capabilities.md): Canonical current enforcement/install/evidence boundary per surface.
259
260
  * [Repository Runtime & Workflow Contract](docs/repository-contract.md): Generated Node/action/workflow/gate state used by consistency CI.
260
261
  * [OpenAI / ChatGPT Plugin Packaging](docs/openai-plugin.md): Local Codex/OpenAI plugin packaging plus public Skills-only submission boundary.
@@ -45,9 +45,15 @@ const persistDirect = (tool, input, cwd = repo, response = {}) => node('hooks/sc
45
45
  tool_response: response,
46
46
  });
47
47
  let toolUseSeq = 0;
48
- function beginShell(command, cwd = repo) {
48
+ function beginShell(command, cwd = repo, extraToolInput = {}) {
49
49
  const toolUseId = 'toolu_fixture_' + (++toolUseSeq);
50
- const input = { ...payload, cwd, tool_name: 'Bash', tool_use_id: toolUseId, tool_input: { command } };
50
+ const input = {
51
+ ...payload,
52
+ cwd,
53
+ tool_name: 'Bash',
54
+ tool_use_id: toolUseId,
55
+ tool_input: { command, ...extraToolInput },
56
+ };
51
57
  return { command, cwd, toolUseId, result: node('hooks/scripts/workflow-gate.js', input) };
52
58
  }
53
59
  function persistShell(call, response = {}, hookEvent = 'PostToolUse') {
@@ -84,6 +90,13 @@ const probeFiles = () => {
84
90
  try { return fs.readdirSync(probeDir).filter(name => /^[a-f0-9]{64}\.json$/.test(name)); }
85
91
  catch (_) { return []; }
86
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
+ }
87
100
  const stop = extra => node('hooks/scripts/workflow-stop-gate.js', { ...payload, ...extra });
88
101
  const control = (...args) => node('hooks/scripts/workflow-disposition.js', null, [args[0], '--session-id', sessionId, ...args.slice(1)]);
89
102
  function fixtureSession(fixtureSessionId) {
@@ -190,6 +203,58 @@ try {
190
203
  git(['init']); fs.writeFileSync(path.join(repo, 'README.md'), 'fixture\n'); git(['add', '.']);
191
204
  git(['-c', 'user.name=Harness Test', '-c', 'user.email=harness@example.invalid', 'commit', '-m', 'fixture']);
192
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
+
193
258
  const taskNotification = '<task-notification><summary>Background command "Run full test suite" failed with exit code 1</summary></task-notification>';
194
259
  const unboundNotification = node('harness-everything/scripts/kernel-router.js', { cwd: repo, prompt: taskNotification });
195
260
  check(unboundNotification.status === 0 &&
@@ -524,6 +589,112 @@ try {
524
589
  'no-effect untrusted shell command does not advance lastMutationAt');
525
590
  }
526
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
+
527
698
  const deniedCall = beginShell('node denied-by-auto-mode.js');
528
699
  check(deniedCall.result.status === 0 && probeFiles().length === 1,
529
700
  'auto-mode candidate reserves mutation capacity before permission decision');
@@ -0,0 +1,114 @@
1
+ # Enforcement and Lock Diagnostic Flows
2
+
3
+ Harness has several mechanisms that can deny a tool call or delay completion. Only one of them is a repeated-failure circuit breaker. Use the emitted mechanism name and the flows below before describing a denial as a lock problem.
4
+
5
+ This guide describes the repository's current hook wiring. Mechanical enforcement depends on the host and installation surface; see [Platform Capability Matrix](platform-capabilities.md) before generalizing a behavior to every host.
6
+
7
+ ## Lifecycle map
8
+
9
+ On the Claude hook surface, a request enters the router before tool use. Pre-tool gates decide whether a tool call may start, post-tool hooks record outcomes, and stop gates decide whether the turn may finish.
10
+
11
+ ```mermaid
12
+ flowchart TD
13
+ A["User prompt"] --> B["Kernel router<br/>select workflow contract"]
14
+ B --> C["Pre-tool gates"]
15
+ C --> D{"Tool allowed?"}
16
+ D -- "No" --> E["Emit gate-specific reason<br/>and recovery"]
17
+ D -- "Yes" --> F["Execute tool"]
18
+ F --> G["Post-tool outcome tracking"]
19
+ G --> H{"Turn stopping?"}
20
+ H -- "No" --> C
21
+ H -- "Yes" --> I["Completion gates"]
22
+ I --> J{"Obligations resolved?"}
23
+ J -- "Yes" --> K["Evidence-backed completion"]
24
+ J -- "No" --> E
25
+ ```
26
+
27
+ The important separation is:
28
+
29
+ - **Pre-tool denial:** the requested operation did not run.
30
+ - **Post-tool tracking:** the operation ran and its outcome updated state.
31
+ - **Stop denial:** the turn cannot claim completion yet; it does not necessarily prevent the next tool call.
32
+ - **Approval request:** the exact destructive or external action waits for authorization; it is not a retry lock.
33
+
34
+ ## Which mechanism stopped progress?
35
+
36
+ Start with the prefix in the error or hook message. Do not infer the mechanism only from the fact that a command did not run.
37
+
38
+ ```mermaid
39
+ flowchart TD
40
+ A["Operation or completion blocked"] --> B{"Message prefix or condition"}
41
+ B -- "RULE OF 3" --> C["Repeated-failure circuit breaker"]
42
+ B -- "Workflow gate" --> D["Workflow entry, state,<br/>run, or worktree precondition"]
43
+ B -- "Action gate or permission" --> E["Exact side effect needs approval"]
44
+ B -- "Workflow Completion Gate" --> F["Selected workflow obligations unresolved"]
45
+ B -- "Stop Gate" --> G["Edits lack later successful verification"]
46
+ B -- "Boundary, depth, context,<br/>or scope guard" --> H["Guard-specific policy denial"]
47
+ C --> I["Follow Rule of 3 recovery flow"]
48
+ D --> J["Satisfy named workflow precondition"]
49
+ E --> K["Approve, reject, or change exact payload"]
50
+ F --> L["Resolve stages or report blocked state"]
51
+ G --> M["Run a relevant check or state why not applicable"]
52
+ H --> N["Follow the emitted guard reason"]
53
+ ```
54
+
55
+ If a message has no Harness prefix, check the host permission system and the tool's own error before changing Harness state.
56
+
57
+ ## Mechanism reference
58
+
59
+ | Mechanism | Trigger | What it blocks | State and persistence | Recovery |
60
+ |---|---|---|---|---|
61
+ | Rule of 3 | The same normalized shell failure signature reaches its category threshold. Defaults to 3; timeout and permission use 2; environment and dependency use 4. | Mutating tools. Read-only investigation remains available, and the required zoom-out report is the only permitted write. | Session-scoped `rule-of-3-state.json`. A second trip of the same signature after one accepted reflection becomes a human-decision hard lock. | First trip: produce a fresh valid `zoom-out-report.md`. Repeat trip: human runs `npm run harness:reset`, starts a new session, or uses `/clear`. |
62
+ | Workflow gate | Active workflow state or entry contract is incompatible with the requested mutation; examples include blocked/failed/satisfied state, missing correlated Fable run, or missing linked worktree for major work. | The requested mutation. Read-only commands and trusted workflow-controller commands have narrow exceptions. | Session workflow state. Major-workflow isolation is a hard precondition, not an automatic fallback. | Use the exact message: enter a linked worktree, start/replan through the controller, resolve blocked state, or submit a new task after satisfaction. |
63
+ | Action gate | A shell rule or structured destructive/external action matches the policy. | Only the exact action payload; its hash is used for audit correlation. | Approval/audit state records pending, executed, failed, or rejected disposition. | Approve or reject through the host flow, or change the action. Approval of one payload does not authorize a different payload. |
64
+ | Workflow completion gate | A selected workflow is stopping with unresolved stage, correlation, or verification obligations. | Completion. On a repeated stop-hook attempt it records a blocked state instead of looping forever. | `workflow-run.json` plus correlated stage/handoff state. | Resolve the named obligation. If genuinely blocked, report the blocker rather than claim completion. |
65
+ | Ordinary stop gate | A dirty tree contains edits with no later successful verification-like command. | Completion once per edit batch. It is intentionally a bounce, not a persistent cage. | `stop-gate-state.json` remembers the edit timestamp already bounced. | Run a targeted test/build/lint check, or explicitly state why verification is not applicable and stop again. |
66
+ | Boundary/depth/context/scope guards | A guard-specific read, write, context-pressure, or subagent-scope condition matches. | The matching tool or workflow step. | Mechanism-specific; these are not Rule of 3 counters. | Follow the emitted reason and the corresponding architecture documentation. |
67
+
68
+ ## Rule of 3 state transitions
69
+
70
+ The tracker hashes a normalized failure together with category, file path, and command context. A different signature starts a new counter. A confirmed successful action clears recovery state.
71
+
72
+ ```mermaid
73
+ stateDiagram-v2
74
+ [*] --> Counting
75
+ Counting --> Counting: same signature below threshold
76
+ Counting --> Reflecting: threshold reached
77
+ Reflecting --> Counting: valid fresh report with RESUME
78
+ Reflecting --> Escalating: valid fresh report with ESCALATE
79
+ Counting --> HardLocked: same signature trips after reflection
80
+ HardLocked --> [*]: human reset or new session
81
+ Counting --> [*]: confirmed success
82
+ Escalating --> HardLocked: mutation retried and signature trips again
83
+ ```
84
+
85
+ A valid reflection report must be newer than the failure that tripped the breaker, contain `Goal`, `Failed Attempts`, `Verified Facts`, `Diagnosis`, and `Decision`, and end in a `RESUME` or `ESCALATE` decision. A stale report cannot release a later trip.
86
+
87
+ ## Pre-tool ordering and overlapping denials
88
+
89
+ The configured Claude pre-tool order is workflow gate, action gate, Rule of 3, then the narrower boundary/depth/context/scope guards for their matched tools. This means the first emitted denial identifies the condition reached on that call; it does not prove that every later gate would pass.
90
+
91
+ For example:
92
+
93
+ 1. A force push may first fail the workflow gate if the selected workflow has not been entered.
94
+ 2. After workflow entry is fixed, the action gate may still require approval for the same force push.
95
+ 3. Rule of 3 is relevant only when repeated tracked failures reached the signature threshold; it should not be reset to bypass workflow or approval requirements.
96
+
97
+ Treat each denial independently and re-evaluate after satisfying its stated condition.
98
+
99
+ ## Host and evidence boundary
100
+
101
+ The hook sequence above is the canonical Claude package wiring. The local OpenAI/Codex plugin packages related lifecycle adapters, while general installer paths and the public Skills-only artifact may be advisory rather than mechanically enforced. OpenCode has a separate plugin implementation and a narrower retained live-host evidence boundary.
102
+
103
+ Repository mechanism tests show that packaged code follows its tested contract. They do not by themselves prove that a particular live host loaded the package, fired the hook, or improved agent behavior. Record live-host evidence before making that stronger claim.
104
+
105
+ ## Source map
106
+
107
+ Use these implementation files when this guide and runtime appear to disagree:
108
+
109
+ - `hooks/hooks.json` — lifecycle wiring and order.
110
+ - `hooks/scripts/rule-of-3.js` and `rule-of-3-tracker.js` — counter, thresholds, reflection, and hard-lock behavior.
111
+ - `hooks/scripts/workflow-gate.js` and `workflow-stop-gate.js` — mutation and completion workflow contracts.
112
+ - `hooks/scripts/action-gate.js` and `action-gate-rules.json` — approval policy and audit behavior.
113
+ - `hooks/scripts/stop-gate.js` — ordinary one-bounce verification behavior.
114
+ - [Harness Architecture](architecture.md) and [Workflow Runtime](workflow-runtime.md) — broader runtime contracts and limitations.
@@ -76,10 +76,18 @@ function workspaceFingerprint(cwd) {
76
76
  function shellProbeKey(payload, cwd) {
77
77
  const toolUseId = String(payload?.tool_use_id || payload?.toolUseId || '').trim();
78
78
  const tool = String(payload?.tool_name || payload?.tool || '');
79
+ if (toolUseId) {
80
+ // Host tool-use identity is stable across Pre/Post payload cwd drift.
81
+ return crypto.createHash('sha256')
82
+ .update('harness-shell-probe-v3\0')
83
+ .update(toolUseId).update('\0')
84
+ .update(tool)
85
+ .digest('hex');
86
+ }
87
+ // Compatibility fallback for hosts that do not expose tool_use_id.
79
88
  const command = commandOf(payload);
80
89
  return crypto.createHash('sha256')
81
- .update('harness-shell-probe-v2\0')
82
- .update(toolUseId || 'no-tool-use-id').update('\0')
90
+ .update('harness-shell-probe-v2-fallback\0')
83
91
  .update(tool).update('\0')
84
92
  .update(key(cwd)).update('\0')
85
93
  .update(command)
@@ -127,6 +135,11 @@ function linkedWorktree(cwd, boundRoot) {
127
135
  // Unsupported shell syntax still fails closed.
128
136
  const DANGEROUS_FLAG_RE = /\s(?:--(?:output|ext-diff|textconv|exec|pre|pre-glob|pager|open|batch|filters)|-[xoO])(?:\b|=)|\s-(?:exec|execdir|delete|fprint|fprintf)\b/i;
129
137
  const READ_ONLY_PREFIX_RE = /^(?:git\s+(?:status|rev-parse|diff|log|show|ls-files|check-ignore)(?:\s|$)|git\s+branch\s+--show-current$|git\s+worktree\s+list(?:\s|$)|gh\s+(?:issue|pr)\s+(?:view|list)(?:\s|$)|gh\s+repo\s+view(?:\s|$)|gh\s+auth\s+status(?:\s|$)|(?:pwd|ls|dir|cat|type|head|tail|wc|stat|rg|grep|echo|Get-Location|Get-ChildItem|Get-Content|Select-String|Test-Path)(?:\s|$))/i;
138
+ const VERIFY_COMMAND_RE = /\b(test|spec|jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|lint|build|compile|verify|check)\b/i;
139
+
140
+ function isVerificationShell(command) {
141
+ return VERIFY_COMMAND_RE.test(String(command || ''));
142
+ }
130
143
 
131
144
  function isReadOnlySegment(segment) {
132
145
  return READ_ONLY_PREFIX_RE.test(segment) && !DANGEROUS_FLAG_RE.test(segment);
@@ -303,4 +316,4 @@ function assertShellScope(command, cwd, isolatedRoot) {
303
316
  }
304
317
  }
305
318
 
306
- module.exports = { canonical, key, within, linkedWorktree, classifyShell, workspaceFingerprint, shellProbeKey, inputOf, cwdOf, commandOf, mutationPaths, worktreeRoots, directMutationInWorkspace, assertTargets, assertShellScope };
319
+ module.exports = { canonical, key, within, linkedWorktree, classifyShell, isVerificationShell, workspaceFingerprint, shellProbeKey, inputOf, cwdOf, commandOf, mutationPaths, worktreeRoots, directMutationInWorkspace, assertTargets, assertShellScope };
@@ -4,6 +4,7 @@ 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');
7
8
 
8
9
  const OPEN_STATES = new Set(['pending', 'active', 'running', 'failed', 'blocked', 'escaped']);
9
10
  const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
@@ -45,6 +46,25 @@ function saveWorkflow(context) { atomicWriteJson(context.file, context.workflow)
45
46
 
46
47
  const MUTATION_PROBE_KEY = /^[a-f0-9]{64}$/;
47
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
+ }
48
68
 
49
69
  function mutationProbeDir(context) {
50
70
  return path.join(context.sessionDir, 'mutation-probes');
@@ -81,15 +101,25 @@ function withMutationProbeLock(context, callback) {
81
101
  finally { fs.rmSync(lockDir, { recursive: true, force: true }); }
82
102
  }
83
103
 
104
+ function probeCountsIteration(probe) {
105
+ return Boolean(probe && (probe.countIteration === true ||
106
+ (probe.countIteration === undefined && probe.reserveIteration === true)));
107
+ }
108
+
84
109
  function mutationProbeReservations(context) {
85
110
  const dir = mutationProbeDir(context);
86
111
  let files = [];
87
112
  try { files = fs.readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)); }
88
113
  catch (_) { return 0; }
114
+ const now = Date.now();
89
115
  let count = 0;
90
116
  for (const file of files) {
91
117
  const probe = readJson(path.join(dir, file));
92
- if (probe?.schemaVersion === 1 && probe.reserveIteration === true) count++;
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++;
93
123
  }
94
124
  return count;
95
125
  }
@@ -115,22 +145,95 @@ function assertIterationCapacity(context, evidence) {
115
145
  return { applicable: true, value: budget.counters.iterations, reserved, limit };
116
146
  }
117
147
 
118
- function registerMutationProbe(context, key, fingerprint, reserveIteration) {
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 = {}) {
119
200
  if (!MUTATION_PROBE_KEY.test(String(key || '')) || !MUTATION_PROBE_KEY.test(String(fingerprint || ''))) {
120
201
  throw new Error('invalid mutation probe identity');
121
202
  }
122
203
  return withMutationProbeLock(context, () => {
123
204
  refreshWorkflowForProbe(context);
124
- if (reserveIteration) assertIterationCapacity(context, 'shell:untrusted');
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
+ }
125
223
  const dir = mutationProbeDir(context);
126
224
  fs.mkdirSync(dir, { recursive: true });
127
225
  const file = mutationProbeFile(context, key);
128
226
  if (fs.existsSync(file)) throw new Error('mutation probe is already pending for this tool call');
227
+ const observedAt = Date.now();
129
228
  const probe = {
130
229
  schemaVersion: 1,
131
230
  fingerprint,
132
- reserveIteration: Boolean(reserveIteration),
133
- observedAt: Date.now(),
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),
134
237
  };
135
238
  atomicWriteJson(file, probe);
136
239
  return probe;
@@ -152,7 +255,7 @@ function settleMutationProbe(context, key, afterFingerprint, tool) {
152
255
  const changed = afterFingerprint !== probe.fingerprint;
153
256
  if (changed) {
154
257
  context.workflow.lastMutationAt = Date.now();
155
- if (probe.reserveIteration && context.workflow.strategy === 'iterative-single') {
258
+ if (probeCountsIteration(probe) && context.workflow.strategy === 'iterative-single') {
156
259
  recordBudgetEvent(context, 'iteration', { evidence: String(tool || 'shell') + ':observed-workspace-mutation' });
157
260
  } else {
158
261
  saveWorkflow(context);
@@ -170,7 +273,7 @@ function settleMutationProbeConservative(context, key, tool, evidence = 'unobser
170
273
  refreshWorkflowForProbe(context);
171
274
  fs.unlinkSync(file);
172
275
  context.workflow.lastMutationAt = Date.now();
173
- if (probe.reserveIteration && context.workflow.strategy === 'iterative-single') {
276
+ if (probeCountsIteration(probe) && context.workflow.strategy === 'iterative-single') {
174
277
  recordBudgetEvent(context, 'iteration', { evidence: String(tool || 'shell') + ':' + evidence });
175
278
  } else {
176
279
  saveWorkflow(context);
@@ -3,14 +3,9 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { getWorkspaceRoot, getSessionDir } = require('./lib/harness-state');
5
5
  const { loadWorkflow, saveWorkflow, isMajorWorkflow, peekMutationProbe, settleMutationProbe, settleMutationProbeConservative } = require('./lib/workflow-runtime');
6
- const { cwdOf, shellProbeKey, workspaceFingerprint, directMutationInWorkspace } = require('./lib/workflow-isolation');
6
+ const { cwdOf, shellProbeKey, isVerificationShell, workspaceFingerprint, directMutationInWorkspace } = require('./lib/workflow-isolation');
7
7
  const { observeTool } = require('./lib/telemetry');
8
8
 
9
- // Commands that count as "verification ran" for the Stop gate
10
- // (hooks/scripts/stop-gate.js). Recall over precision: under-blocking the
11
- // gate is the correct failure direction, so a broad net is fine.
12
- const VERIFY_COMMAND_RE = /\b(test|spec|jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|lint|build|compile|verify|check)\b/i;
13
-
14
9
  let inputData = '';
15
10
  const timeout = setTimeout(() => {
16
11
  processState(null);
@@ -37,10 +32,12 @@ function observeWorkspaceMutation(payload, root) {
37
32
  if (!context.workflow || context.workflow.state === 'deferred') return false;
38
33
  const cwd = cwdOf(payload, root);
39
34
  const probeKey = shellProbeKey(payload, cwd);
40
- if (!peekMutationProbe(context, probeKey)) return false;
35
+ const probe = peekMutationProbe(context, probeKey);
36
+ if (!probe) return false;
37
+ const observationCwd = probe.observationCwd || cwd;
41
38
  let after;
42
39
  try {
43
- after = workspaceFingerprint(cwd);
40
+ after = workspaceFingerprint(observationCwd);
44
41
  } catch (error) {
45
42
  if (!isMajorWorkflow(context.workflow)) {
46
43
  const conservative = settleMutationProbeConservative(context, probeKey, toolName, 'unobservable-workspace');
@@ -125,7 +122,7 @@ function processState(payload) {
125
122
  }
126
123
  if ((toolName === 'Bash' || toolName === 'PowerShell') && !isFailed) {
127
124
  const command = (payload.tool_input && payload.tool_input.command) || '';
128
- if (VERIFY_COMMAND_RE.test(command)) {
125
+ if (isVerificationShell(command)) {
129
126
  state.lastVerifyAt = Date.now();
130
127
  state.lastVerifyExitCode = exitCode ?? null;
131
128
  }
@@ -4,11 +4,20 @@
4
4
  const path = require('path');
5
5
  const fs = require('fs');
6
6
  const { loadWorkflow, saveWorkflow, isMajorWorkflow, matchingRun, registerMutationProbe, recordBudgetEvent, readHookInput, WORKFLOW_CONTROLLER_COMMANDS } = require('./lib/workflow-runtime');
7
- const { key, classifyShell, workspaceFingerprint, shellProbeKey, cwdOf, commandOf, mutationPaths, directMutationInWorkspace, linkedWorktree, assertTargets, assertShellScope } = require('./lib/workflow-isolation');
7
+ const { key, classifyShell, isVerificationShell, workspaceFingerprint, shellProbeKey, cwdOf, commandOf, mutationPaths, directMutationInWorkspace, linkedWorktree, assertTargets, assertShellScope } = require('./lib/workflow-isolation');
8
8
 
9
9
  const DIRECT = new Set(['Edit', 'Write', 'apply_patch']);
10
10
  const SHELL = new Set(['Bash', 'PowerShell', 'exec_command']);
11
11
 
12
+ function shellTimeoutMs(payload) {
13
+ const input = payload?.tool_input || payload?.input || {};
14
+ for (const value of [input.timeout_ms, input.timeoutMs, input.timeout, payload.timeout_ms, payload.timeoutMs]) {
15
+ const parsed = Number(value);
16
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
17
+ }
18
+ return null;
19
+ }
20
+
12
21
  function isController(command, cwd) {
13
22
  // One trusted local executable, literal arguments, no shell evaluation.
14
23
  if (/[;&|`\r\n<>$(){}]/.test(command)) return false;
@@ -79,7 +88,12 @@ function decide(payload) {
79
88
  return;
80
89
  }
81
90
  const probeKey = shellProbeKey(payload, cwd);
82
- registerMutationProbe(context, probeKey, fingerprint, workflow.strategy === 'iterative-single');
91
+ const iterative = workflow.strategy === 'iterative-single';
92
+ registerMutationProbe(context, probeKey, fingerprint, iterative, cwd, {
93
+ countIteration: iterative,
94
+ allowAtLimit: iterative && isVerificationShell(command),
95
+ timeoutMs: shellTimeoutMs(payload),
96
+ });
83
97
  }
84
98
 
85
99
  readHookInput(decide);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-enforcement",
3
3
  "description": "Repository inventory for the opencode enforcement plugin and its standalone helper",
4
- "version": "0.12.0",
4
+ "version": "0.12.2",
5
5
  "runtime": "index.mjs",
6
6
  "hooks": {
7
7
  "verify": "hooks/verify.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Harness OS: An Orchestrated Agent Operating System for AI-driven Development",
5
5
  "main": "harness-everything/scripts/bootstrap.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
5
5
  "author": {
6
6
  "name": "Miya Daniel",
@@ -76,10 +76,18 @@ function workspaceFingerprint(cwd) {
76
76
  function shellProbeKey(payload, cwd) {
77
77
  const toolUseId = String(payload?.tool_use_id || payload?.toolUseId || '').trim();
78
78
  const tool = String(payload?.tool_name || payload?.tool || '');
79
+ if (toolUseId) {
80
+ // Host tool-use identity is stable across Pre/Post payload cwd drift.
81
+ return crypto.createHash('sha256')
82
+ .update('harness-shell-probe-v3\0')
83
+ .update(toolUseId).update('\0')
84
+ .update(tool)
85
+ .digest('hex');
86
+ }
87
+ // Compatibility fallback for hosts that do not expose tool_use_id.
79
88
  const command = commandOf(payload);
80
89
  return crypto.createHash('sha256')
81
- .update('harness-shell-probe-v2\0')
82
- .update(toolUseId || 'no-tool-use-id').update('\0')
90
+ .update('harness-shell-probe-v2-fallback\0')
83
91
  .update(tool).update('\0')
84
92
  .update(key(cwd)).update('\0')
85
93
  .update(command)
@@ -127,6 +135,11 @@ function linkedWorktree(cwd, boundRoot) {
127
135
  // Unsupported shell syntax still fails closed.
128
136
  const DANGEROUS_FLAG_RE = /\s(?:--(?:output|ext-diff|textconv|exec|pre|pre-glob|pager|open|batch|filters)|-[xoO])(?:\b|=)|\s-(?:exec|execdir|delete|fprint|fprintf)\b/i;
129
137
  const READ_ONLY_PREFIX_RE = /^(?:git\s+(?:status|rev-parse|diff|log|show|ls-files|check-ignore)(?:\s|$)|git\s+branch\s+--show-current$|git\s+worktree\s+list(?:\s|$)|gh\s+(?:issue|pr)\s+(?:view|list)(?:\s|$)|gh\s+repo\s+view(?:\s|$)|gh\s+auth\s+status(?:\s|$)|(?:pwd|ls|dir|cat|type|head|tail|wc|stat|rg|grep|echo|Get-Location|Get-ChildItem|Get-Content|Select-String|Test-Path)(?:\s|$))/i;
138
+ const VERIFY_COMMAND_RE = /\b(test|spec|jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|lint|build|compile|verify|check)\b/i;
139
+
140
+ function isVerificationShell(command) {
141
+ return VERIFY_COMMAND_RE.test(String(command || ''));
142
+ }
130
143
 
131
144
  function isReadOnlySegment(segment) {
132
145
  return READ_ONLY_PREFIX_RE.test(segment) && !DANGEROUS_FLAG_RE.test(segment);
@@ -303,4 +316,4 @@ function assertShellScope(command, cwd, isolatedRoot) {
303
316
  }
304
317
  }
305
318
 
306
- module.exports = { canonical, key, within, linkedWorktree, classifyShell, workspaceFingerprint, shellProbeKey, inputOf, cwdOf, commandOf, mutationPaths, worktreeRoots, directMutationInWorkspace, assertTargets, assertShellScope };
319
+ module.exports = { canonical, key, within, linkedWorktree, classifyShell, isVerificationShell, workspaceFingerprint, shellProbeKey, inputOf, cwdOf, commandOf, mutationPaths, worktreeRoots, directMutationInWorkspace, assertTargets, assertShellScope };
@@ -4,6 +4,7 @@ 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');
7
8
 
8
9
  const OPEN_STATES = new Set(['pending', 'active', 'running', 'failed', 'blocked', 'escaped']);
9
10
  const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
@@ -45,6 +46,25 @@ function saveWorkflow(context) { atomicWriteJson(context.file, context.workflow)
45
46
 
46
47
  const MUTATION_PROBE_KEY = /^[a-f0-9]{64}$/;
47
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
+ }
48
68
 
49
69
  function mutationProbeDir(context) {
50
70
  return path.join(context.sessionDir, 'mutation-probes');
@@ -81,15 +101,25 @@ function withMutationProbeLock(context, callback) {
81
101
  finally { fs.rmSync(lockDir, { recursive: true, force: true }); }
82
102
  }
83
103
 
104
+ function probeCountsIteration(probe) {
105
+ return Boolean(probe && (probe.countIteration === true ||
106
+ (probe.countIteration === undefined && probe.reserveIteration === true)));
107
+ }
108
+
84
109
  function mutationProbeReservations(context) {
85
110
  const dir = mutationProbeDir(context);
86
111
  let files = [];
87
112
  try { files = fs.readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)); }
88
113
  catch (_) { return 0; }
114
+ const now = Date.now();
89
115
  let count = 0;
90
116
  for (const file of files) {
91
117
  const probe = readJson(path.join(dir, file));
92
- if (probe?.schemaVersion === 1 && probe.reserveIteration === true) count++;
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++;
93
123
  }
94
124
  return count;
95
125
  }
@@ -115,22 +145,95 @@ function assertIterationCapacity(context, evidence) {
115
145
  return { applicable: true, value: budget.counters.iterations, reserved, limit };
116
146
  }
117
147
 
118
- function registerMutationProbe(context, key, fingerprint, reserveIteration) {
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 = {}) {
119
200
  if (!MUTATION_PROBE_KEY.test(String(key || '')) || !MUTATION_PROBE_KEY.test(String(fingerprint || ''))) {
120
201
  throw new Error('invalid mutation probe identity');
121
202
  }
122
203
  return withMutationProbeLock(context, () => {
123
204
  refreshWorkflowForProbe(context);
124
- if (reserveIteration) assertIterationCapacity(context, 'shell:untrusted');
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
+ }
125
223
  const dir = mutationProbeDir(context);
126
224
  fs.mkdirSync(dir, { recursive: true });
127
225
  const file = mutationProbeFile(context, key);
128
226
  if (fs.existsSync(file)) throw new Error('mutation probe is already pending for this tool call');
227
+ const observedAt = Date.now();
129
228
  const probe = {
130
229
  schemaVersion: 1,
131
230
  fingerprint,
132
- reserveIteration: Boolean(reserveIteration),
133
- observedAt: Date.now(),
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),
134
237
  };
135
238
  atomicWriteJson(file, probe);
136
239
  return probe;
@@ -152,7 +255,7 @@ function settleMutationProbe(context, key, afterFingerprint, tool) {
152
255
  const changed = afterFingerprint !== probe.fingerprint;
153
256
  if (changed) {
154
257
  context.workflow.lastMutationAt = Date.now();
155
- if (probe.reserveIteration && context.workflow.strategy === 'iterative-single') {
258
+ if (probeCountsIteration(probe) && context.workflow.strategy === 'iterative-single') {
156
259
  recordBudgetEvent(context, 'iteration', { evidence: String(tool || 'shell') + ':observed-workspace-mutation' });
157
260
  } else {
158
261
  saveWorkflow(context);
@@ -170,7 +273,7 @@ function settleMutationProbeConservative(context, key, tool, evidence = 'unobser
170
273
  refreshWorkflowForProbe(context);
171
274
  fs.unlinkSync(file);
172
275
  context.workflow.lastMutationAt = Date.now();
173
- if (probe.reserveIteration && context.workflow.strategy === 'iterative-single') {
276
+ if (probeCountsIteration(probe) && context.workflow.strategy === 'iterative-single') {
174
277
  recordBudgetEvent(context, 'iteration', { evidence: String(tool || 'shell') + ':' + evidence });
175
278
  } else {
176
279
  saveWorkflow(context);
@@ -3,14 +3,9 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { getWorkspaceRoot, getSessionDir } = require('./lib/harness-state');
5
5
  const { loadWorkflow, saveWorkflow, isMajorWorkflow, peekMutationProbe, settleMutationProbe, settleMutationProbeConservative } = require('./lib/workflow-runtime');
6
- const { cwdOf, shellProbeKey, workspaceFingerprint, directMutationInWorkspace } = require('./lib/workflow-isolation');
6
+ const { cwdOf, shellProbeKey, isVerificationShell, workspaceFingerprint, directMutationInWorkspace } = require('./lib/workflow-isolation');
7
7
  const { observeTool } = require('./lib/telemetry');
8
8
 
9
- // Commands that count as "verification ran" for the Stop gate
10
- // (hooks/scripts/stop-gate.js). Recall over precision: under-blocking the
11
- // gate is the correct failure direction, so a broad net is fine.
12
- const VERIFY_COMMAND_RE = /\b(test|spec|jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|lint|build|compile|verify|check)\b/i;
13
-
14
9
  let inputData = '';
15
10
  const timeout = setTimeout(() => {
16
11
  processState(null);
@@ -37,10 +32,12 @@ function observeWorkspaceMutation(payload, root) {
37
32
  if (!context.workflow || context.workflow.state === 'deferred') return false;
38
33
  const cwd = cwdOf(payload, root);
39
34
  const probeKey = shellProbeKey(payload, cwd);
40
- if (!peekMutationProbe(context, probeKey)) return false;
35
+ const probe = peekMutationProbe(context, probeKey);
36
+ if (!probe) return false;
37
+ const observationCwd = probe.observationCwd || cwd;
41
38
  let after;
42
39
  try {
43
- after = workspaceFingerprint(cwd);
40
+ after = workspaceFingerprint(observationCwd);
44
41
  } catch (error) {
45
42
  if (!isMajorWorkflow(context.workflow)) {
46
43
  const conservative = settleMutationProbeConservative(context, probeKey, toolName, 'unobservable-workspace');
@@ -125,7 +122,7 @@ function processState(payload) {
125
122
  }
126
123
  if ((toolName === 'Bash' || toolName === 'PowerShell') && !isFailed) {
127
124
  const command = (payload.tool_input && payload.tool_input.command) || '';
128
- if (VERIFY_COMMAND_RE.test(command)) {
125
+ if (isVerificationShell(command)) {
129
126
  state.lastVerifyAt = Date.now();
130
127
  state.lastVerifyExitCode = exitCode ?? null;
131
128
  }
@@ -4,11 +4,20 @@
4
4
  const path = require('path');
5
5
  const fs = require('fs');
6
6
  const { loadWorkflow, saveWorkflow, isMajorWorkflow, matchingRun, registerMutationProbe, recordBudgetEvent, readHookInput, WORKFLOW_CONTROLLER_COMMANDS } = require('./lib/workflow-runtime');
7
- const { key, classifyShell, workspaceFingerprint, shellProbeKey, cwdOf, commandOf, mutationPaths, directMutationInWorkspace, linkedWorktree, assertTargets, assertShellScope } = require('./lib/workflow-isolation');
7
+ const { key, classifyShell, isVerificationShell, workspaceFingerprint, shellProbeKey, cwdOf, commandOf, mutationPaths, directMutationInWorkspace, linkedWorktree, assertTargets, assertShellScope } = require('./lib/workflow-isolation');
8
8
 
9
9
  const DIRECT = new Set(['Edit', 'Write', 'apply_patch']);
10
10
  const SHELL = new Set(['Bash', 'PowerShell', 'exec_command']);
11
11
 
12
+ function shellTimeoutMs(payload) {
13
+ const input = payload?.tool_input || payload?.input || {};
14
+ for (const value of [input.timeout_ms, input.timeoutMs, input.timeout, payload.timeout_ms, payload.timeoutMs]) {
15
+ const parsed = Number(value);
16
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
17
+ }
18
+ return null;
19
+ }
20
+
12
21
  function isController(command, cwd) {
13
22
  // One trusted local executable, literal arguments, no shell evaluation.
14
23
  if (/[;&|`\r\n<>$(){}]/.test(command)) return false;
@@ -79,7 +88,12 @@ function decide(payload) {
79
88
  return;
80
89
  }
81
90
  const probeKey = shellProbeKey(payload, cwd);
82
- registerMutationProbe(context, probeKey, fingerprint, workflow.strategy === 'iterative-single');
91
+ const iterative = workflow.strategy === 'iterative-single';
92
+ registerMutationProbe(context, probeKey, fingerprint, iterative, cwd, {
93
+ countIteration: iterative,
94
+ allowAtLimit: iterative && isVerificationShell(command),
95
+ timeoutMs: shellTimeoutMs(payload),
96
+ });
83
97
  }
84
98
 
85
99
  readHookInput(decide);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "harness-everything",
4
- "version": "0.12.0",
4
+ "version": "0.12.2",
5
5
  "description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
6
6
  "author": {
7
7
  "name": "Miya Daniel",