harness-everything 0.11.5 → 0.11.7

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 (55) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +32 -0
  4. package/README.md +7 -3
  5. package/ci/mechanism-2a-rule-of-3.test.js +71 -0
  6. package/ci/mechanism-2ah-workflow-lifecycle.test.js +314 -8
  7. package/ci/mechanism-2c-state-persist.test.js +45 -2
  8. package/ci/mechanism-2t-issue-20.test.js +0 -8
  9. package/ci/mechanism-30-opencode-plugin-loadability.test.js +39 -7
  10. package/docs/architecture.md +17 -0
  11. package/docs/audit.md +52 -5
  12. package/docs/release-evidence.md +3 -0
  13. package/docs/workflow-runtime.md +13 -0
  14. package/docs/workflows/environment-detection.md +47 -37
  15. package/docs/workflows/eval-harness.md +45 -38
  16. package/docs/workflows/fable-discipline.md +45 -33
  17. package/docs/workflows/git-commit.md +53 -45
  18. package/docs/workflows/grill-me.md +36 -38
  19. package/docs/workflows/grill-with-docs.md +36 -46
  20. package/docs/workflows/harness-everything.md +59 -57
  21. package/docs/workflows/improve-codebase-architecture.md +36 -44
  22. package/docs/workflows/install-cognitive-os.md +36 -29
  23. package/docs/workflows/repo-docs.md +29 -40
  24. package/docs/workflows/rewrite-commits.md +40 -36
  25. package/docs/workflows/security-review.md +34 -53
  26. package/docs/workflows/skill-creator.md +39 -56
  27. package/docs/workflows/skill-style.md +35 -40
  28. package/docs/workflows/using-git-worktrees.md +40 -44
  29. package/docs/workflows/verification-loop.md +43 -48
  30. package/docs/workflows/verify-before-claim.md +37 -40
  31. package/docs/workflows/zoom-out.md +39 -51
  32. package/harness-everything/SKILL.md +1 -1
  33. package/harness-everything/scripts/kernel-router.js +38 -10
  34. package/hooks/hooks.json +25 -1
  35. package/hooks/scripts/lib/workflow-isolation.js +111 -6
  36. package/hooks/scripts/lib/workflow-runtime.js +138 -2
  37. package/hooks/scripts/rule-of-3-tracker.js +17 -9
  38. package/hooks/scripts/state-persist.js +52 -9
  39. package/hooks/scripts/workflow-gate.js +22 -7
  40. package/hooks/scripts/workflow-mutation-denied.js +17 -0
  41. package/opencode-plugin/plugin.json +1 -1
  42. package/package.json +1 -1
  43. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  44. package/plugins/harness-everything/hooks/hooks.json +1 -1
  45. package/plugins/harness-everything/hooks/scripts/lib/workflow-isolation.js +111 -6
  46. package/plugins/harness-everything/hooks/scripts/lib/workflow-runtime.js +138 -2
  47. package/plugins/harness-everything/hooks/scripts/rule-of-3-tracker.js +17 -9
  48. package/plugins/harness-everything/hooks/scripts/state-persist.js +52 -9
  49. package/plugins/harness-everything/hooks/scripts/workflow-gate.js +22 -7
  50. package/plugins/harness-everything/hooks/scripts/workflow-mutation-denied.js +17 -0
  51. package/plugins/harness-everything/plugin.json +1 -1
  52. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  53. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router.js +38 -10
  54. package/docs/issue-20-rollup.md +0 -47
  55. package/docs/reviews/pr-142-issue-131.md +0 -89
@@ -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.11.5",
13
+ "version": "0.11.7",
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.11.5",
4
+ "version": "0.11.7",
5
5
  "author": {
6
6
  "name": "Miya Daniel"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## [0.11.7](https://github.com/dyphn1/Harness-everything/compare/v0.11.6...v0.11.7) (2026-09-18)
6
+
7
+ ### Bug Fixes
8
+
9
+ * Claude success path, rule-of-3 category, worktree scan ([fd8c9e7](https://github.com/dyphn1/Harness-everything/commit/fd8c9e79261a18a79883029f7179d94c18ec302e)), closes [#153](https://github.com/dyphn1/Harness-everything/issues/153) [#166](https://github.com/dyphn1/Harness-everything/issues/166) [#167](https://github.com/dyphn1/Harness-everything/issues/167) [#170](https://github.com/dyphn1/Harness-everything/issues/170)
10
+ * fail closed on unbound host notifications ([48faf8c](https://github.com/dyphn1/Harness-everything/commit/48faf8caff38e2cc77d2eae0169d6ba62767d7de))
11
+ * fail closed on unbound host notifications ([fea7201](https://github.com/dyphn1/Harness-everything/commit/fea72018ba74b6dc2cf6d273b4116a3503fdcda2))
12
+ * Host notification routing and out-of-workspace edit accounting ([508a321](https://github.com/dyphn1/Harness-everything/commit/508a32141389612f8fc1c92f89ebe4fde88cee1e)), closes [#172](https://github.com/dyphn1/Harness-everything/issues/172) [#165](https://github.com/dyphn1/Harness-everything/issues/165) [#168](https://github.com/dyphn1/Harness-everything/issues/168)
13
+ * ignore host notifications before routing ([ad355fd](https://github.com/dyphn1/Harness-everything/commit/ad355fdd98f45747b467d034adc4bf6fbcf1404b))
14
+ * ignore host notifications before routing ([ea96577](https://github.com/dyphn1/Harness-everything/commit/ea96577d6f2916261a3a4484bfdb5358c828d475))
15
+ * integrate Claude PostToolUse semantics ([6c3b447](https://github.com/dyphn1/Harness-everything/commit/6c3b447e7112c8a8984ad304de37c8819f17be27))
16
+ * integrate Claude PostToolUse semantics ([a7077e9](https://github.com/dyphn1/Harness-everything/commit/a7077e9240b76a53367198c32a9d0c2645fbbc32))
17
+
18
+ ## [0.11.6](https://github.com/dyphn1/Harness-everything/compare/v0.11.5...v0.11.6) (2026-09-18)
19
+
20
+ ### Bug Fixes
21
+
22
+ * **workflow:** avoid workflow rewrite after probe registration ([b9f2e06](https://github.com/dyphn1/Harness-everything/commit/b9f2e065114d70e927b6f75e7c0f7899d9e8392f))
23
+ * **workflow:** correlate mutation probes by tool-use identity ([86cd1a1](https://github.com/dyphn1/Harness-everything/commit/86cd1a1c6eaef6518936160b0c35f94a3e652cae))
24
+ * **workflow:** defer shell mutation accounting to observer ([c543fd0](https://github.com/dyphn1/Harness-everything/commit/c543fd0c41e18f05c21235ce4ffcf28864200940))
25
+ * **workflow:** discard denied shell mutation probes ([7a8757c](https://github.com/dyphn1/Harness-everything/commit/7a8757cab821e524ccdd17e416e013bfbadc4f39))
26
+ * **workflow:** fingerprint observed workspace effects ([9d8e316](https://github.com/dyphn1/Harness-everything/commit/9d8e316eb97ec7bbcd0e46ceb51491af90e57e9f))
27
+ * **workflow:** fingerprint visible content independent of staging ([a1f370e](https://github.com/dyphn1/Harness-everything/commit/a1f370e64a97b04bee4d8df23e3ffdc7552377b8))
28
+ * **workflow:** isolate mutation probes per tool call ([70d6052](https://github.com/dyphn1/Harness-everything/commit/70d6052ef02fce1125da9a20a14a4ad65d09ba93))
29
+ * **workflow:** make mutation observation single-handler ordered ([ea166e1](https://github.com/dyphn1/Harness-everything/commit/ea166e1a94cdd1725cd7242feb5b0716a4dce8b8))
30
+ * **workflow:** normalize deleted paths in fingerprint ([e5019da](https://github.com/dyphn1/Harness-everything/commit/e5019daee870e806d77d92c741a9624cd6c9bfe0))
31
+ * **workflow:** observe shell effects before state persistence ([5271655](https://github.com/dyphn1/Harness-everything/commit/5271655627344eb71ffeb8dd36b007709862b9d0))
32
+ * **workflow:** observe shell workspace mutations ([c547b39](https://github.com/dyphn1/Harness-everything/commit/c547b396bf2e3829b83ed03abeca594b7391f542))
33
+ * **workflow:** remove parallel standalone mutation observer ([20027f6](https://github.com/dyphn1/Harness-everything/commit/20027f68f5b63719ea7a96a524f49c7103586644))
34
+ * **workflow:** reserve shell mutation probes ([e7273f6](https://github.com/dyphn1/Harness-everything/commit/e7273f66010d5b0c740b87b8dbc020c8de616fe7))
35
+ * **workflow:** settle mutation probes inside state persistence ([88feaf4](https://github.com/dyphn1/Harness-everything/commit/88feaf4ee3accbd8504b745fd4e79e548c31bd6d))
36
+
5
37
  ## [0.11.5](https://github.com/dyphn1/Harness-everything/compare/v0.11.4...v0.11.5) (2026-09-18)
6
38
 
7
39
  ### Bug Fixes
package/README.md CHANGED
@@ -82,6 +82,7 @@ The general installer only writes to your workspace (or, with `--global`, your h
82
82
  | `AGENTS.md` + `.agents/skills/` | Codex advisory instructions plus repo-scoped Agent Skills; Hermes can also consume trusted project skills from `.agents/skills/` |
83
83
  | `.continue/rules/harness.md` + `.continue/skills/` | Continue.dev advisory rule and installer candidate skill path; standalone skill discovery is `Unknown` |
84
84
  | `.hermes.md` | Hermes Agent project advisory context |
85
+ | `.opencode/plugins/harness-enforcement.js` (manual copy, not installer-owned) | OpenCode enforcement plugin — the general installer has no `--opencode` path; copy `opencode-plugin/index.mjs` under a `.js` name per [opencode-plugin/README.md](opencode-plugin/README.md) |
85
86
  | `.claude/harness-everything/` (or the per-platform equivalent) | Harness installer/runtime bookkeeping owned by that integration |
86
87
 
87
88
  For `--global`, the installer uses each host's supported user-level skill location rather than assuming one shared directory works everywhere: shared Agent Skills remain under `~/.agents/skills/` where natively consumed, Continue uses `~/.continue/skills/`, Hermes uses `~/.hermes/skills/`, and Claude uses `~/.claude/skills/`.
@@ -212,11 +213,14 @@ This repo uses a flat layout (waza/agentskills.io convention). The table below m
212
213
  | `plugins/harness-everything` | **Distribution** | Local OpenAI/Codex plugin package plus canonical skill copies |
213
214
  | `submission/openai` | **Distribution / Review** | Public OpenAI Skills-only listing/test inputs |
214
215
  | `evals` | **Routing Evals** | 26 trigger/routing eval suites (waza format) |
216
+ | `eval-framework` | **Quality Gates** | Negative-control fixtures for consistency/collision gates (not a skill) |
217
+ | `contract-integrity` | **Quality Gates** | ADR→spec→ticket→test→implementation trace audit (Phase 1; not a directly routed skill) |
218
+ | `telemetry` | **Quality Gates** | Local JSONL operational evidence layer with report/benchmark scripts (not a skill) |
215
219
  | `behavioral-evals` | **Behavioral Evals** | LLM-level discipline cases plus weekly structural validation workflow |
216
220
  | `benchmarks` | **Benchmarks** | BENCHMARK_SOP fixtures and recorded A/B results |
217
221
  | `docs` | **Documentation** | Philosophy, architecture, routing, reflection, platform capabilities, generated repository contract, audit |
218
222
  | `references` | **Documentation** | Shared checklists (security, performance, definition-of-done) |
219
- | `multi-agent-workspace` | **Skill (Tier 3)** | Scaffold six zones, select agency specialists, and generate bounded launchers |
223
+ | `multi-agent-workspace` | **Skill (Tier 3)** | Scaffold a verified multi-agent workspace and select bounded specialists from an external catalog without vendoring the full roster |
220
224
  | `environment-detection` | **Foundation** | Preflight: detect OS, shell, package manager |
221
225
  | `eval-harness` | **Skill (Tier 2)** | Evaluate agent outputs against rubrics |
222
226
  | `fable-discipline` | **Skill (Tier 3)** | Fable execution guardrails when Fable is selected |
@@ -260,7 +264,7 @@ For a deep dive into individual modules and the underlying philosophy, explore o
260
264
 
261
265
  Fable model selection is documented in [fable-mode/references/model-matrix.md](fable-mode/references/model-matrix.md); the explicit entrypoints are `fable-haiku`, `fable-sonnet`, and `fable-opus`.
262
266
 
263
- Maintainers should follow [RELEASING.md](RELEASING.md) for tag-driven npm releases and record observations in [docs/release-evidence.md](docs/release-evidence.md). The issue #20 coordination decisions and evidence boundaries are captured in [docs/issue-20-rollup.md](docs/issue-20-rollup.md).
267
+ Maintainers should follow [RELEASING.md](RELEASING.md) for tag-driven npm releases and record observations in [docs/release-evidence.md](docs/release-evidence.md). Issue #20 is closed (2026-09-10); its coordination history lives in git.
264
268
 
265
269
  ---
266
270
 
@@ -294,7 +298,7 @@ Mechanism tests prove individual packaged mechanisms; only real host/session evi
294
298
 
295
299
  Harness audits itself on a dated cycle by running its own test suite and VERIFICATION.md recipes — never by reading the code and assuming it works. The full scorecards, methodology, and per-cycle change log live in [docs/audit.md](docs/audit.md). Audit scorecards are historical snapshots; current platform capability claims live in [docs/platform-capabilities.md](docs/platform-capabilities.md).
296
300
 
297
- **Latest local audit baseline — 2026-09-01**: 26/26 on-disk skills, 26/26 routing-eval directories, and the local npm gates were green for that audit snapshot. Waza remained a CI-only gate in that checkout because its installer was not available as an npm package; run it on an LF-normalized export as documented by CI.
301
+ **Latest local audit baseline — 2026-09-18** (macOS, Node.js 24; see [docs/audit.md](docs/audit.md)): 26/26 on-disk skills, 26/26 routing-eval directories, 34/34 positive routes, 275/275 invariant checks, and all 26 canonical skills within the 500-token limit. Deterministic gates were green except the pre-existing `mechanism-30` failure caused by leftover `.worktrees` fixtures (reproduced on the clean tree). Waza full-matrix spec verify and live model sessions remain on-demand evidence; run waza on an LF-normalized export as documented by CI.
298
302
 
299
303
  Measure on an LF export, not a Windows working tree — CRLF can inflate waza's token counts and trigger false budget failures.
300
304
 
@@ -68,6 +68,77 @@ helper.check(
68
68
  `Got exit=${successResult.code}, state=${JSON.stringify(resetState)}`
69
69
  );
70
70
 
71
+ helper.runHook('rule-of-3-tracker.js', claudeFailurePayload);
72
+ const claudeNoExitSuccess = helper.runHook('rule-of-3-tracker.js', {
73
+ hook_event_name: 'PostToolUse',
74
+ tool_name: 'Bash',
75
+ tool_input: { command: 'echo recovered' },
76
+ tool_response: {
77
+ stdout: 'recovered',
78
+ stderr: 'warning: successful command emitted an error-like diagnostic',
79
+ },
80
+ session_id: helper.SESSION_ID,
81
+ });
82
+ const noExitResetState = helper.readState('rule-of-3-state.json');
83
+ helper.check(
84
+ '2a-claude-success-no-exit. PostToolUse without exitCode resets even with stderr diagnostics',
85
+ claudeNoExitSuccess.code === 0 &&
86
+ noExitResetState.count === 0 &&
87
+ noExitResetState.zoomOutResolved === true,
88
+ `Got exit=${claudeNoExitSuccess.code}, state=${JSON.stringify(noExitResetState)}`
89
+ );
90
+
91
+ helper.writeState('rule-of-3-state.json', {
92
+ count: 0, lastHash: null, zoomOutResolved: false, zoomOutCycles: 0, lastFailureAt: 0,
93
+ });
94
+ helper.runHook('rule-of-3-tracker.js', {
95
+ hook_event_name: 'PostToolUseFailure',
96
+ tool_name: 'Bash',
97
+ tool_input: { command: 'diff -rq hooks cache-hooks' },
98
+ error: 'Exit code 1\nworkflow-mutation-denied.js\nFiles hooks/a.js and cache-hooks/a.js differ',
99
+ session_id: helper.SESSION_ID,
100
+ });
101
+ const deniedFilenameState = helper.readState('rule-of-3-state.json');
102
+ helper.check(
103
+ '2a-category-negative. File name containing denied stays unknown/threshold 3',
104
+ deniedFilenameState.category === 'unknown' && deniedFilenameState.threshold === 3,
105
+ `State=${JSON.stringify(deniedFilenameState)}`
106
+ );
107
+
108
+ helper.writeState('rule-of-3-state.json', {
109
+ count: 0, lastHash: null, zoomOutResolved: false, zoomOutCycles: 0, lastFailureAt: 0,
110
+ });
111
+ helper.runHook('rule-of-3-tracker.js', {
112
+ hook_event_name: 'PostToolUseFailure',
113
+ tool_name: 'Bash',
114
+ tool_input: { command: 'cat protected.txt' },
115
+ error: 'EACCES: permission denied, open protected.txt',
116
+ session_id: helper.SESSION_ID,
117
+ });
118
+ const permissionState = helper.readState('rule-of-3-state.json');
119
+ helper.check(
120
+ '2a-category-permission. Real EACCES is permission/threshold 2',
121
+ permissionState.category === 'permission' && permissionState.threshold === 2,
122
+ `State=${JSON.stringify(permissionState)}`
123
+ );
124
+
125
+ helper.writeState('rule-of-3-state.json', {
126
+ count: 0, lastHash: null, zoomOutResolved: false, zoomOutCycles: 0, lastFailureAt: 0,
127
+ });
128
+ helper.runHook('rule-of-3-tracker.js', {
129
+ hook_event_name: 'PostToolUseFailure',
130
+ tool_name: 'Bash',
131
+ tool_input: { command: 'node slow.js' },
132
+ error: 'TimeoutError: operation timed out',
133
+ session_id: helper.SESSION_ID,
134
+ });
135
+ const timeoutState = helper.readState('rule-of-3-state.json');
136
+ helper.check(
137
+ '2a-category-timeout. Real timeout is timeout/threshold 2',
138
+ timeoutState.category === 'timeout' && timeoutState.threshold === 2,
139
+ `State=${JSON.stringify(timeoutState)}`
140
+ );
141
+
71
142
  helper.writeState('rule-of-3-state.json', { count: 3, lastHash: 'mech-test', zoomOutResolved: false });
72
143
  const tripResult = helper.runHook('rule-of-3.js', { session_id: helper.SESSION_ID });
73
144
  helper.check(
@@ -35,6 +35,54 @@ function git(args) {
35
35
  }
36
36
  const route = prompt => node('harness-everything/scripts/kernel-router.js', { ...payload, prompt });
37
37
  const gate = (tool, input, cwd = linked) => node('hooks/scripts/workflow-gate.js', { ...payload, cwd, tool_name: tool, tool_input: input });
38
+ const persistDirect = (tool, input, cwd = repo, response = {}) => node('hooks/scripts/state-persist.js', {
39
+ ...payload,
40
+ cwd,
41
+ hook_event_name: 'PostToolUse',
42
+ tool_name: tool,
43
+ tool_input: input,
44
+ tool_response: response,
45
+ });
46
+ let toolUseSeq = 0;
47
+ function beginShell(command, cwd = repo) {
48
+ const toolUseId = 'toolu_fixture_' + (++toolUseSeq);
49
+ const input = { ...payload, cwd, tool_name: 'Bash', tool_use_id: toolUseId, tool_input: { command } };
50
+ return { command, cwd, toolUseId, result: node('hooks/scripts/workflow-gate.js', input) };
51
+ }
52
+ function persistShell(call, response = {}, hookEvent = 'PostToolUse') {
53
+ const input = {
54
+ ...payload,
55
+ cwd: call.cwd,
56
+ hook_event_name: hookEvent,
57
+ tool_name: 'Bash',
58
+ tool_use_id: call.toolUseId,
59
+ tool_input: { command: call.command },
60
+ };
61
+ if (hookEvent === 'PostToolUseFailure') {
62
+ input.error = response.error || 'Exit code 1\nfixture failure';
63
+ input.is_interrupt = Boolean(response.is_interrupt);
64
+ } else {
65
+ input.tool_response = response;
66
+ }
67
+ return node('hooks/scripts/state-persist.js', input);
68
+ }
69
+ function denyShell(call) {
70
+ return node('hooks/scripts/workflow-mutation-denied.js', {
71
+ ...payload,
72
+ cwd: call.cwd,
73
+ hook_event_name: 'PermissionDenied',
74
+ permission_mode: 'auto',
75
+ tool_name: 'Bash',
76
+ tool_use_id: call.toolUseId,
77
+ tool_input: { command: call.command },
78
+ reason: 'Blocked by classifier',
79
+ });
80
+ }
81
+ const probeDir = path.join(sessionDir, 'mutation-probes');
82
+ const probeFiles = () => {
83
+ try { return fs.readdirSync(probeDir).filter(name => /^[a-f0-9]{64}\.json$/.test(name)); }
84
+ catch (_) { return []; }
85
+ };
38
86
  const stop = extra => node('hooks/scripts/workflow-stop-gate.js', { ...payload, ...extra });
39
87
  const control = (...args) => node('hooks/scripts/workflow-disposition.js', null, [args[0], '--session-id', sessionId, ...args.slice(1)]);
40
88
  const stages = [
@@ -47,6 +95,20 @@ try {
47
95
  git(['init']); fs.writeFileSync(path.join(repo, 'README.md'), 'fixture\n'); git(['add', '.']);
48
96
  git(['-c', 'user.name=Harness Test', '-c', 'user.email=harness@example.invalid', 'commit', '-m', 'fixture']);
49
97
  git(['worktree', 'add', linked, '-b', 'isolated']);
98
+ const taskNotification = '<task-notification><summary>Background command "Run full test suite" failed with exit code 1</summary></task-notification>';
99
+ const unboundNotification = node('harness-everything/scripts/kernel-router.js', { cwd: repo, prompt: taskNotification });
100
+ check(unboundNotification.status === 0 &&
101
+ unboundNotification.stdout.includes('ignored for routing') &&
102
+ !unboundNotification.stdout.includes('WORKFLOW EXECUTION CONTRACT'),
103
+ 'host notification without session binding is a routing no-op');
104
+ const noWorkflowNotification = route(taskNotification);
105
+ check(noWorkflowNotification.status === 0 && !fs.existsSync(file),
106
+ 'host notification with no active workflow creates no contract');
107
+ check(noWorkflowNotification.stdout.includes('ignored for routing') &&
108
+ !noWorkflowNotification.stdout.includes('iterative-single') &&
109
+ !noWorkflowNotification.stdout.includes('WORKFLOW EXECUTION CONTRACT'),
110
+ 'host notification with no active workflow emits no notification-derived routing contract');
111
+
50
112
  const routed = route('Refactor the entire repository architecture in dependent stages.');
51
113
  check(routed.status === 0, 'prompt hook creates workflow');
52
114
  const initial = read(file);
@@ -109,6 +171,15 @@ try {
109
171
  check(stop().status === 2, 'verification predating a new mutation is stale');
110
172
  write(file, mutationState);
111
173
  check(stop().status === 0 && read(file).state === 'satisfied', 'all covered stages and verifier resolve workflow to satisfied');
174
+ const satisfiedWorkflow = read(file);
175
+ const satisfiedNotification = route(taskNotification);
176
+ check(satisfiedNotification.status === 0 &&
177
+ read(file).workflowId === satisfiedWorkflow.workflowId &&
178
+ read(file).state === 'satisfied',
179
+ 'satisfied workflow followed by host notification keeps the same satisfied contract');
180
+ check(!satisfiedNotification.stdout.includes('Run full test suite') &&
181
+ satisfiedNotification.stdout.includes('routing unchanged'),
182
+ 'satisfied host notification text is not routed as new work');
112
183
  check(route('Refactor the entire repository architecture in dependent stages.').status === 0 && read(file).workflowId !== initial.workflowId, 'next completed-task boundary creates a fresh workflow');
113
184
  check(stop({ stop_hook_active: true }).status === 0 && read(file).state === 'blocked', 'Stop retry reports blocked instead of faking completion');
114
185
  check(gate('Write', { file_path: path.join(linked, 'src.js') }).status === 2, 'blocked state still prohibits mutation');
@@ -156,9 +227,117 @@ try {
156
227
  check(gate('Write', { file_path: path.join(repo, 'premature-primary.js') }, repo).status === 2, 'activated Tier 3 plan still blocks primary-tree mutation');
157
228
  fs.unlinkSync(file);
158
229
  check(route('Fix this checkout bug with a regression test').status === 0 && read(file).strategy === 'iterative-single', 'fresh bounded fix returns to Tier 2 iterative lifecycle');
230
+ let tier2 = read(file);
231
+ const tier2WorkflowId = tier2.workflowId;
232
+ write(file, { ...tier2, state: 'deferred' });
233
+ const deferredNotification = route(taskNotification);
234
+ check(deferredNotification.status === 0 &&
235
+ read(file).workflowId === tier2WorkflowId &&
236
+ read(file).state === 'deferred',
237
+ 'deferred workflow followed by host notification keeps the same deferred contract');
238
+ check(!deferredNotification.stdout.includes('Run full test suite') &&
239
+ deferredNotification.stdout.includes('routing unchanged'),
240
+ 'deferred host notification text is not routed as new work');
241
+ write(file, tier2);
242
+
159
243
  const iterationLimit = read(file).workflowPlan.limits.maxIterations;
160
244
  check(Number.isInteger(iterationLimit) && iterationLimit > 0, 'iterative route exposes a numeric iteration budget');
161
245
 
246
+ // #165: direct Edit/Write/apply_patch accounting follows workspace effects.
247
+ // Bookkeeping outside the repository must not consume Tier-2 iterations or
248
+ // advance workflow/Stop-gate mutation milestones.
249
+ const handoffFile = path.join(sessionDir, 'handoff-state.json');
250
+ const outsideTarget = path.join(temp, 'scratch-note.txt');
251
+ const beforeOutside = read(file);
252
+ const beforeOutsideIterations = beforeOutside.budget?.counters?.iterations || 0;
253
+ const beforeOutsideMutationAt = beforeOutside.lastMutationAt || 0;
254
+ const beforeOutsideEditAt = fs.existsSync(handoffFile) ? (read(handoffFile).lastEditAt || 0) : 0;
255
+ for (let i = 0; i < 3; i++) {
256
+ check(gate('Write', { file_path: outsideTarget }, repo).status === 0,
257
+ 'out-of-workspace Write is admitted without Tier-2 mutation accounting');
258
+ fs.writeFileSync(outsideTarget, 'scratch ' + i + '\n');
259
+ const outsideResponse = i === 0
260
+ ? { stderr: 'warning: successful bookkeeping write emitted diagnostic text' }
261
+ : {};
262
+ check(persistDirect('Write', { file_path: outsideTarget }, repo, outsideResponse).status === 0,
263
+ 'out-of-workspace Write post-tool state persists without mutation milestone');
264
+ }
265
+ const afterOutside = read(file);
266
+ const afterOutsideHandoff = read(handoffFile);
267
+ check((afterOutside.budget?.counters?.iterations || 0) === beforeOutsideIterations &&
268
+ (afterOutside.lastMutationAt || 0) === beforeOutsideMutationAt &&
269
+ (afterOutsideHandoff.lastEditAt || 0) === beforeOutsideEditAt &&
270
+ afterOutsideHandoff.status !== 'failed',
271
+ 'out-of-workspace Writes leave iterations/milestones unchanged and PostToolUse stderr is not a failure');
272
+ check(stop().status === 0, 'out-of-workspace-only bookkeeping does not force verification before Stop');
273
+
274
+ check(route('Fix this checkout bug with a regression test').status === 0 && read(file).strategy === 'iterative-single',
275
+ 'real follow-up user prompt after notification/accounting checks routes normally');
276
+ let observedDirectIterations = read(file).budget?.counters?.iterations || 0;
277
+ const internalTarget = path.join(repo, 'direct-inside.txt');
278
+ const beforeInternalMutationAt = read(file).lastMutationAt || 0;
279
+ check(gate('Write', { file_path: internalTarget }, repo).status === 0, 'in-workspace Write is admitted');
280
+ fs.writeFileSync(internalTarget, 'inside\n');
281
+ check(persistDirect('Write', { file_path: internalTarget }, repo).status === 0, 'in-workspace Write post-tool state persists');
282
+ check(read(file).budget.counters.iterations === ++observedDirectIterations &&
283
+ read(file).lastMutationAt >= beforeInternalMutationAt &&
284
+ read(handoffFile).lastEditAt > beforeOutsideEditAt,
285
+ 'in-workspace Write consumes exactly one iteration and advances mutation milestones');
286
+
287
+ const mixedPatch = [
288
+ '*** Begin Patch',
289
+ `*** Update File: ${outsideTarget}`,
290
+ '@@',
291
+ '-scratch',
292
+ '+scratch updated',
293
+ `*** Update File: ${path.join(repo, 'README.md')}`,
294
+ '@@',
295
+ '-fixture',
296
+ '+fixture updated',
297
+ '*** End Patch',
298
+ ].join('\n');
299
+ check(gate('apply_patch', { patch: mixedPatch }, repo).status === 0, 'mixed apply_patch is admitted');
300
+ check(persistDirect('apply_patch', { patch: mixedPatch }, repo).status === 0, 'mixed apply_patch post-tool state persists');
301
+ check(read(file).budget.counters.iterations === ++observedDirectIterations,
302
+ 'mixed apply_patch counts exactly once when any target is inside the workspace');
303
+
304
+ const pureOutsidePatch = [
305
+ '*** Begin Patch',
306
+ `*** Update File: ${outsideTarget}`,
307
+ '@@',
308
+ '-scratch updated',
309
+ '+scratch external only',
310
+ '*** End Patch',
311
+ ].join('\n');
312
+ const beforePureOutside = read(file);
313
+ const beforePureOutsideEditAt = read(handoffFile).lastEditAt || 0;
314
+ check(gate('apply_patch', { patch: pureOutsidePatch }, repo).status === 0, 'pure external apply_patch is admitted');
315
+ check(persistDirect('apply_patch', { patch: pureOutsidePatch }, repo).status === 0, 'pure external apply_patch post-tool state persists');
316
+ check(read(file).budget.counters.iterations === observedDirectIterations &&
317
+ read(file).lastMutationAt === beforePureOutside.lastMutationAt &&
318
+ (read(handoffFile).lastEditAt || 0) === beforePureOutsideEditAt,
319
+ 'pure external apply_patch consumes no iteration and advances no mutation milestone');
320
+
321
+ const linkedTarget = path.join(linked, 'direct-linked.txt');
322
+ check(gate('Write', { file_path: linkedTarget }, linked).status === 0,
323
+ 'Write inside a linked worktree is treated as workspace mutation');
324
+ fs.writeFileSync(linkedTarget, 'linked\n');
325
+ check(persistDirect('Write', { file_path: linkedTarget }, linked).status === 0,
326
+ 'linked-worktree Write post-tool state persists');
327
+ check(read(file).budget.counters.iterations === ++observedDirectIterations,
328
+ 'linked-worktree Write consumes exactly one iteration');
329
+
330
+ // Keep the #165 regression fixture from consuming the budget used by the
331
+ // pre-existing lifecycle/exhaustion assertions below.
332
+ fs.unlinkSync(internalTarget);
333
+ fs.unlinkSync(linkedTarget);
334
+ fs.unlinkSync(file);
335
+ if (fs.existsSync(handoffFile)) fs.unlinkSync(handoffFile);
336
+ check(route('Fix this checkout bug with a regression test').status === 0 &&
337
+ read(file).strategy === 'iterative-single' &&
338
+ (read(file).budget?.counters?.iterations || 0) === 0,
339
+ '#165 regression fixture resets into a fresh Tier-2 lifecycle baseline');
340
+
162
341
  // #155/#157 regression lock: read-only shell composition, including quoted
163
342
  // and escaped separator characters inside arguments, must never look like an
164
343
  // iterative mutation or advance the mutation clock.
@@ -183,10 +362,132 @@ try {
183
362
  check((afterReadOnly.lastMutationAt || 0) === readOnlyMutationAt,
184
363
  'read-only shell composition does not advance lastMutationAt');
185
364
 
186
- for (let i = 0; i < iterationLimit; i++) {
187
- check(gate('Write', { file_path: path.join(repo, `small-${i}.js`) }, repo).status === 0, `iterative mutation ${i + 1}/${iterationLimit} stays within budget`);
365
+ // #159: safety classification is not mutation evidence. Commands that are
366
+ // not proven read-only are admitted under an opaque workspace probe while
367
+ // capacity remains, then accounted only if the visible workspace changed.
368
+ const untrustedReads = [
369
+ 'git fetch origin --quiet 2>&1; git status; git log --oneline -1',
370
+ 'git remote -v',
371
+ 'gh run view 1 --json status',
372
+ 'node -e "console.log(42)"',
373
+ ];
374
+ for (const command of untrustedReads) {
375
+ const before = read(file);
376
+ const call = beginShell(command);
377
+ check(call.result.status === 0, 'untrusted read/query is admitted under observation: ' + command);
378
+ const probes = probeFiles();
379
+ check(probes.length === 1 && !fs.readFileSync(path.join(probeDir, probes[0]), 'utf8').includes(command),
380
+ 'mutation probe persists only opaque identity, not raw command text');
381
+ check(persistShell(call).status === 0, 'no-effect shell probe resolves through state-persist: ' + command);
382
+ check(probeFiles().length === 0, 'completed shell call removes its mutation probe');
383
+ const after = read(file);
384
+ check((after.budget?.counters?.iterations || 0) === (before.budget?.counters?.iterations || 0),
385
+ 'no-effect untrusted shell command consumes no iteration');
386
+ check((after.lastMutationAt || 0) === (before.lastMutationAt || 0),
387
+ 'no-effect untrusted shell command does not advance lastMutationAt');
388
+ }
389
+
390
+ const deniedCall = beginShell('node denied-by-auto-mode.js');
391
+ check(deniedCall.result.status === 0 && probeFiles().length === 1,
392
+ 'auto-mode candidate reserves mutation capacity before permission decision');
393
+ check(denyShell(deniedCall).status === 0 && probeFiles().length === 0,
394
+ 'PermissionDenied releases the exact tool-use mutation probe');
395
+ const parallelA = beginShell('node repeated-read-only-wrapper.js');
396
+ const parallelB = beginShell('node repeated-read-only-wrapper.js');
397
+ check(parallelA.result.status === 0 && parallelB.result.status === 0 && probeFiles().length === 2,
398
+ 'identical concurrent shell commands receive distinct tool-use probes');
399
+ check(denyShell(parallelA).status === 0 && persistShell(parallelB).status === 0 && probeFiles().length === 0,
400
+ 'parallel tool-use probes settle independently without reservation leakage');
401
+
402
+ const metadataFetch = 'git fetch . HEAD:refs/remotes/origin/harness-probe';
403
+ const beforeMetadataFetch = read(file);
404
+ const metadataFetchCall = beginShell(metadataFetch);
405
+ check(metadataFetchCall.result.status === 0,
406
+ 'metadata-only git fetch is admitted under observation');
407
+ git(['fetch', '.', 'HEAD:refs/remotes/origin/harness-probe']);
408
+ check(persistShell(metadataFetchCall).status === 0, 'real metadata-only git fetch probe resolves');
409
+ check((read(file).budget?.counters?.iterations || 0) === (beforeMetadataFetch.budget?.counters?.iterations || 0) &&
410
+ (read(file).lastMutationAt || 0) === (beforeMetadataFetch.lastMutationAt || 0),
411
+ 'real git fetch changes Git metadata without consuming a code iteration');
412
+
413
+ let observedIterations = read(file).budget?.counters?.iterations || 0;
414
+ const mutateTracked = 'node mutate-tracked.js';
415
+ const mutateTrackedCall = beginShell(mutateTracked);
416
+ check(mutateTrackedCall.result.status === 0, 'unknown tracked-file mutator is admitted under observation');
417
+ fs.writeFileSync(path.join(repo, 'README.md'), 'fixture changed once\n');
418
+ check(persistShell(mutateTrackedCall).status === 0, 'tracked-file mutation is observed');
419
+ check(read(file).budget.counters.iterations === ++observedIterations, 'tracked-file mutation consumes exactly one iteration');
420
+ const firstObservedAt = read(file).lastMutationAt;
421
+
422
+ const mutateDirty = 'node mutate-dirty-again.js';
423
+ const mutateDirtyCall = beginShell(mutateDirty);
424
+ check(mutateDirtyCall.result.status === 0, 'unknown already-dirty mutator is admitted under observation');
425
+ fs.writeFileSync(path.join(repo, 'README.md'), 'fixture changed twice\n');
426
+ check(persistShell(mutateDirtyCall).status === 0, 'already-dirty content change is observed');
427
+ check(read(file).budget.counters.iterations === ++observedIterations && read(file).lastMutationAt >= firstObservedAt,
428
+ 'already-dirty content change consumes one new iteration');
429
+
430
+ const createUntracked = 'node create-untracked.js';
431
+ const createUntrackedCall = beginShell(createUntracked);
432
+ check(createUntrackedCall.result.status === 0, 'unknown untracked-file mutator is admitted under observation');
433
+ fs.writeFileSync(path.join(repo, 'observed-untracked.txt'), 'one\n');
434
+ check(persistShell(createUntrackedCall).status === 0, 'untracked-file creation is observed');
435
+ check(read(file).budget.counters.iterations === ++observedIterations, 'untracked-file creation consumes exactly one iteration');
436
+
437
+ const failedMutation = 'node fail-after-write.js';
438
+ const failedMutationCall = beginShell(failedMutation);
439
+ check(failedMutationCall.result.status === 0, 'failing shell mutator is admitted under observation');
440
+ fs.writeFileSync(path.join(repo, 'observed-failed.txt'), 'written before failure\n');
441
+ check(persistShell(failedMutationCall, { error: 'Exit code 7\nfixture failure' }, 'PostToolUseFailure').status === 0,
442
+ 'failed shell command still reports workspace mutation through failure lifecycle');
443
+ check(read(file).budget.counters.iterations === ++observedIterations,
444
+ 'failed shell command that changed workspace still consumes one iteration');
445
+
446
+ const beforeStageOnly = read(file);
447
+ const stageOnly = 'git add README.md observed-untracked.txt';
448
+ const stageOnlyCall = beginShell(stageOnly);
449
+ check(stageOnlyCall.result.status === 0, 'staging-only git command is observed instead of pre-counted');
450
+ git(['add', 'README.md', 'observed-untracked.txt']);
451
+ check(persistShell(stageOnlyCall).status === 0, 'staging-only probe resolves');
452
+ check(read(file).budget.counters.iterations === beforeStageOnly.budget.counters.iterations,
453
+ 'git add does not double-count tracked or previously-untracked visible content');
454
+
455
+ const beforeCommitOnly = read(file);
456
+ const commitOnly = 'git commit -m observed-fixture';
457
+ const commitOnlyCall = beginShell(commitOnly);
458
+ check(commitOnlyCall.result.status === 0, 'pure commit is observed instead of pre-counted');
459
+ git(['-c', 'user.name=Harness Test', '-c', 'user.email=harness@example.invalid', 'commit', '-m', 'observed-fixture']);
460
+ check(persistShell(commitOnlyCall).status === 0, 'pure commit probe resolves');
461
+ check(read(file).budget.counters.iterations === beforeCommitOnly.budget.counters.iterations,
462
+ 'pure commit of already-accounted content does not consume another code iteration');
463
+
464
+ const deleteTracked = 'node delete-tracked.js';
465
+ const deleteTrackedCall = beginShell(deleteTracked);
466
+ check(deleteTrackedCall.result.status === 0, 'unknown tracked-file deletion is admitted under observation');
467
+ fs.unlinkSync(path.join(repo, 'observed-untracked.txt'));
468
+ check(persistShell(deleteTrackedCall).status === 0, 'tracked-file deletion is observed');
469
+ check(read(file).budget.counters.iterations === ++observedIterations,
470
+ 'tracked-file deletion consumes exactly one iteration');
471
+
472
+ const beforeStageDelete = read(file);
473
+ const stageDelete = 'git add -u observed-untracked.txt';
474
+ const stageDeleteCall = beginShell(stageDelete);
475
+ check(stageDeleteCall.result.status === 0, 'staging-only deletion is observed instead of pre-counted');
476
+ git(['add', '-u', 'observed-untracked.txt']);
477
+ check(persistShell(stageDeleteCall).status === 0, 'staging deletion probe resolves');
478
+ check(read(file).budget.counters.iterations === beforeStageDelete.budget.counters.iterations,
479
+ 'git add -u does not double-count an already-observed deletion');
480
+
481
+ observedIterations = read(file).budget.counters.iterations;
482
+ for (let i = observedIterations; i < iterationLimit; i++) {
483
+ check(gate('Write', { file_path: path.join(repo, `small-${i}.js`) }, repo).status === 0, `confirmed mutation ${i + 1}/${iterationLimit} stays within budget`);
188
484
  }
189
- check(gate('Write', { file_path: path.join(repo, 'small-overflow.js') }, repo).status === 2, 'iteration beyond maxIterations is blocked');
485
+ check(gate('Bash', { command: 'git status --short' }, repo).status === 0,
486
+ 'proven read-only diagnosis remains allowed at the iteration limit');
487
+ check(gate('Bash', { command: 'git remote -v' }, repo).status === 2,
488
+ 'untrusted shell command is blocked before execution once actual mutation budget is full');
489
+ check(gate('Write', { file_path: path.join(repo, 'small-overflow.js') }, repo).status === 2,
490
+ 'blocked exhausted workflow still rejects direct mutation');
190
491
  const iterativeExhausted = read(file);
191
492
  check(iterativeExhausted.state === 'blocked' && iterativeExhausted.budget?.state === 'budget-exhausted' &&
192
493
  iterativeExhausted.budget?.reasonCode === 'iteration-budget-exhausted', 'iterative exhaustion records blocked budget state');
@@ -200,11 +501,16 @@ try {
200
501
  check(stop().status === 2, 'direct/iterative route cannot complete unverified mutation');
201
502
  check(stop({ stop_hook_active: true }).status === 0 && read(file).state === 'blocked', 'iterative retry reports incomplete state');
202
503
  check(control('start').status === 0, 'blocked iterative route can resume without a Fable stage map');
203
- // Issue #153: a host is not guaranteed to report a numeric exit code at
204
- // all (state-persist.js's own isFailed logic already treats "no exit code
205
- // and no stderr" as success), so this must resolve rather than deadlock.
206
- node('hooks/scripts/state-persist.js', { ...payload, tool_name: 'Bash', tool_input: { command: 'npm test' }, tool_response: { stdout: 'no code' } });
207
- check(stop().status === 0 && read(file).state === 'satisfied', 'verification without a host-reported numeric exit status still resolves completion');
504
+ // #153 + #159 ordering: when one shell command mutates and then verifies,
505
+ // observed mutation must land before state-persist records verification.
506
+ const mutateAndVerify = 'node mutate.js && npm test';
507
+ const mutateAndVerifyCall = beginShell(mutateAndVerify);
508
+ check(mutateAndVerifyCall.result.status === 0, 'combined mutation+verification command is admitted under observation');
509
+ fs.writeFileSync(path.join(repo, 'README.md'), 'fixture verified mutation\n');
510
+ check(persistShell(mutateAndVerifyCall, { stdout: 'tests passed' }).status === 0,
511
+ 'single state-persist handler records mutation before verification');
512
+ check(stop().status === 0 && read(file).state === 'satisfied',
513
+ 'verification without a host-reported numeric exit status resolves after same-command mutation');
208
514
  node('hooks/scripts/state-persist.js', { ...payload, tool_name: 'Bash', tool_input: { command: 'npm test' }, tool_response: { exitCode: 0, stdout: 'passed' } });
209
515
  check(stop().status === 0 && read(file).state === 'satisfied', 'observed iterative verification resolves completion');
210
516
  console.log(`PASS: workflow lifecycle (${passed} assertions)`);
@@ -16,14 +16,57 @@ helper.check(
16
16
 
17
17
  helper.runHook('state-persist.js', {
18
18
  session_id: helper.SESSION_ID,
19
+ hook_event_name: 'PostToolUse',
19
20
  tool_name: 'Bash',
20
- tool_response: { stdout: 'ok', exitCode: 0 },
21
+ tool_response: {
22
+ stdout: 'ok',
23
+ stderr: 'warning: successful command emitted diagnostic text',
24
+ },
21
25
  });
22
26
  const resolvedHandoff = helper.readState('handoff-state.json');
23
27
  helper.check(
24
- '2c. A subsequent successful command clears status back to idle',
28
+ '2c. Claude-shaped PostToolUse without exitCode clears failed state even with stderr diagnostics',
25
29
  resolvedHandoff.status === 'idle' && !!resolvedHandoff.lastResolved,
26
30
  `Got ${JSON.stringify(resolvedHandoff)}`
27
31
  );
28
32
 
33
+ helper.runHook('state-persist.js', {
34
+ session_id: helper.SESSION_ID,
35
+ hook_event_name: 'PostToolUse',
36
+ tool_name: 'Bash',
37
+ tool_response: { stdout: '', stderr: 'numeric failure', exitCode: 2 },
38
+ });
39
+ const numericFailure = helper.readState('handoff-state.json');
40
+ helper.check(
41
+ '2c. Numeric non-zero exit still records failure on hosts that report exit codes',
42
+ numericFailure.status === 'failed' && numericFailure.exitCode === 2,
43
+ `Got ${JSON.stringify(numericFailure)}`
44
+ );
45
+
46
+ helper.runHook('state-persist.js', {
47
+ session_id: helper.SESSION_ID,
48
+ hook_event_name: 'PostToolUse',
49
+ tool_name: 'Bash',
50
+ tool_response: { stdout: 'ok', exitCode: 0 },
51
+ });
52
+ const numericResolved = helper.readState('handoff-state.json');
53
+ helper.check(
54
+ '2c. Numeric zero exit still clears failure state',
55
+ numericResolved.status === 'idle' && !!numericResolved.lastResolved,
56
+ `Got ${JSON.stringify(numericResolved)}`
57
+ );
58
+
59
+ helper.runHook('state-persist.js', {
60
+ session_id: helper.SESSION_ID,
61
+ hook_event_name: 'PostToolUseFailure',
62
+ tool_name: 'Bash',
63
+ error: 'host-declared failure without numeric exit code',
64
+ });
65
+ const eventFailure = helper.readState('handoff-state.json');
66
+ helper.check(
67
+ '2c. PostToolUseFailure remains authoritative without numeric exitCode',
68
+ eventFailure.status === 'failed',
69
+ `Got ${JSON.stringify(eventFailure)}`
70
+ );
71
+
29
72
  helper.finish();
@@ -64,12 +64,4 @@ helper.check(
64
64
  `exit ${negativeControls.status}\n${negativeControls.stdout}\n${negativeControls.stderr}`
65
65
  );
66
66
 
67
- const rollup = fs.readFileSync(path.join(helper.root, 'docs', 'issue-20-rollup.md'), 'utf8');
68
- const deferredWork = ['baseline-debugging', 'baseline-performance', 'baseline-security-review', 'baseline-simple-bugfix', 'verify-before-done', 'verification-loop', 'zoom-out', 'security-review'];
69
- helper.check(
70
- '2t. issue rollup records unresolved acceptance work accurately',
71
- /Issue #20 remains open/.test(rollup) && deferredWork.every(term => rollup.includes(term)),
72
- 'rollup must name the open issue and each deferred baseline/skill item'
73
- );
74
-
75
67
  helper.finish();