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
@@ -2,6 +2,8 @@
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { getWorkspaceRoot, getSessionDir } = require('./lib/harness-state');
5
+ const { loadWorkflow, saveWorkflow, peekMutationProbe, settleMutationProbe } = require('./lib/workflow-runtime');
6
+ const { cwdOf, shellProbeKey, workspaceFingerprint, directMutationInWorkspace } = require('./lib/workflow-isolation');
5
7
  const { observeTool } = require('./lib/telemetry');
6
8
 
7
9
  // Commands that count as "verification ran" for the Stop gate
@@ -28,7 +30,31 @@ process.stdin.on('end', () => {
28
30
  }
29
31
  });
30
32
 
33
+ function observeWorkspaceMutation(payload, root) {
34
+ const toolName = payload && (payload.tool_name || payload.tool);
35
+ if (toolName !== 'Bash' && toolName !== 'PowerShell' && toolName !== 'exec_command') return false;
36
+ const context = loadWorkflow(payload);
37
+ if (!context.workflow || context.workflow.state === 'deferred') return false;
38
+ const cwd = cwdOf(payload, root);
39
+ const probeKey = shellProbeKey(payload, cwd);
40
+ if (!peekMutationProbe(context, probeKey)) return false;
41
+ let after;
42
+ try {
43
+ after = workspaceFingerprint(cwd);
44
+ } catch (error) {
45
+ context.workflow.state = 'blocked';
46
+ context.workflow.blockReason = 'mutation-observation-failed';
47
+ saveWorkflow(context);
48
+ const critical = new Error('cannot verify whether shell execution changed workspace content');
49
+ critical.code = 'HARNESS_MUTATION_OBSERVATION';
50
+ throw critical;
51
+ }
52
+ const result = settleMutationProbe(context, probeKey, after, toolName);
53
+ return Boolean(result && result.changed);
54
+ }
55
+
31
56
  function processState(payload) {
57
+ let exitCodeForHook = 0;
32
58
  try {
33
59
  const sessionId = payload && (payload.session_id || payload.sessionId);
34
60
  const root = getWorkspaceRoot(payload);
@@ -48,12 +74,17 @@ function processState(payload) {
48
74
  // it's actually a number; fall back to a stderr signal otherwise.
49
75
  const toolResponse = payload.tool_response || {};
50
76
  const stdout = toolResponse.stdout ?? payload.stdout ?? '';
51
- const stderr = toolResponse.stderr ?? payload.stderr ?? '';
77
+ const stderr = toolResponse.stderr ?? payload.stderr ?? payload.error ?? '';
52
78
  const rawExitCode = toolResponse.exitCode ?? toolResponse.exit_code ?? payload.exitCode;
53
79
  const exitCode = typeof rawExitCode === 'number' ? rawExitCode : undefined;
54
80
  const stderrSignal = typeof stderr === 'string' && stderr.trim().length > 0;
55
- const isFailed = (exitCode !== undefined && exitCode !== 0) || (exitCode === undefined && stderrSignal);
81
+ const hookEvent = payload.hook_event_name || payload.hookEventName || '';
82
+ const isSuccessEvent = hookEvent === 'PostToolUse';
83
+ const isFailed = hookEvent === 'PostToolUseFailure' ||
84
+ (exitCode !== undefined && exitCode !== 0) ||
85
+ (!isSuccessEvent && exitCode === undefined && stderrSignal);
56
86
  const toolName = payload.tool_name || payload.tool || 'command';
87
+ const observedMutation = observeWorkspaceMutation(payload, root);
57
88
 
58
89
  if (isFailed) {
59
90
  // Truncate output to avoid state bloat
@@ -67,8 +98,13 @@ function processState(payload) {
67
98
  state.tool = toolName;
68
99
  state.exitCode = exitCode;
69
100
  state.errorSummary = output.trim();
70
- } else if (exitCode === 0 && state.status === 'failed') {
71
- // Clear failed state or mark as idle/resolved
101
+ } else if (!isFailed && state.status === 'failed') {
102
+ // Clear failed state or mark as idle/resolved.
103
+ // NOTE (#166, follows #153): Claude Code Bash PostToolUse payloads
104
+ // carry no numeric exit code, so `exitCode === 0` never holds there.
105
+ // A PostToolUse event with no numeric non-zero exit status counts as
106
+ // success even when stderr contains diagnostics. Hosts that omit the
107
+ // lifecycle event name retain the conservative stderr fallback.
72
108
  state.lastResolved = { tool: state.tool, timestamp: state.timestamp };
73
109
  state.status = 'idle';
74
110
  state.timestamp = new Date().toISOString();
@@ -77,10 +113,13 @@ function processState(payload) {
77
113
  }
78
114
 
79
115
  // Milestones for the Stop gate: when did the last mutation happen, and
80
- // has any verification-ish command succeeded since.
81
- if (toolName === 'Edit' || toolName === 'Write' || toolName === 'apply_patch') {
116
+ // has any verification-ish command succeeded since. Direct-tool edits
117
+ // outside the workspace advance neither milestone (#165).
118
+ const isDirectTool = toolName === 'Edit' || toolName === 'Write' || toolName === 'apply_patch';
119
+ if ((isDirectTool && directMutationInWorkspace(payload, cwdOf(payload, root), root)) || observedMutation) {
82
120
  state.lastEditAt = Date.now();
83
- } else if ((toolName === 'Bash' || toolName === 'PowerShell') && !isFailed) {
121
+ }
122
+ if ((toolName === 'Bash' || toolName === 'PowerShell') && !isFailed) {
84
123
  const command = (payload.tool_input && payload.tool_input.command) || '';
85
124
  if (VERIFY_COMMAND_RE.test(command)) {
86
125
  state.lastVerifyAt = Date.now();
@@ -100,7 +139,11 @@ function processState(payload) {
100
139
  }
101
140
  }
102
141
  } catch (err) {
103
- // Fail silently in hooks
142
+ if (err && (err.code === 'HARNESS_MUTATION_OBSERVATION' || err.code === 'HARNESS_WORKFLOW_BUDGET')) {
143
+ console.error('[State Persist] ' + err.message);
144
+ exitCodeForHook = 2;
145
+ }
146
+ // Non-critical persistence/telemetry failures remain fail-open.
104
147
  }
105
- process.exit(0);
148
+ process.exit(exitCodeForHook);
106
149
  }
@@ -3,8 +3,8 @@
3
3
 
4
4
  const path = require('path');
5
5
  const fs = require('fs');
6
- const { loadWorkflow, saveWorkflow, isMajorWorkflow, matchingRun, recordBudgetEvent, readHookInput, WORKFLOW_CONTROLLER_COMMANDS } = require('./lib/workflow-runtime');
7
- const { key, classifyShell, cwdOf, commandOf, mutationPaths, linkedWorktree, assertTargets, assertShellScope } = require('./lib/workflow-isolation');
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');
8
8
 
9
9
  const DIRECT = new Set(['Edit', 'Write', 'apply_patch']);
10
10
  const SHELL = new Set(['Bash', 'PowerShell', 'exec_command']);
@@ -27,8 +27,8 @@ function decide(payload) {
27
27
  const cwd = cwdOf(payload, root);
28
28
  const command = commandOf(payload);
29
29
  const commandClass = SHELL.has(tool) ? classifyShell(command) : 'direct-mutation';
30
- if (commandClass === 'read-only' || commandClass === 'worktree-setup') return;
31
30
  if (SHELL.has(tool) && isController(command, cwd)) return;
31
+ if (commandClass === 'read-only' || commandClass === 'worktree-setup') return;
32
32
  // Only the stage specification is allowed before run entry. Other runtime
33
33
  // state, including workflow-run.json, cannot use this bootstrap exception.
34
34
  const targets = DIRECT.has(tool) ? mutationPaths(payload) : [];
@@ -47,11 +47,26 @@ function decide(payload) {
47
47
  if (String(workflow.strategy || '').startsWith('fable-') && !matchingRun(context)) {
48
48
  throw new Error('selected Fable workflow has no correlated run. Write workflow-stages.json at the displayed session path, then use workflow-disposition.js start.');
49
49
  }
50
- if (workflow.strategy === 'iterative-single') {
51
- recordBudgetEvent(context, 'iteration', { evidence: `${tool}:${commandClass}` });
50
+ if (DIRECT.has(tool)) {
51
+ // Bookkeeping outside the workspace (scratchpad, memory files, temp
52
+ // notes) is not a workspace mutation: it consumes no Tier-2 iteration
53
+ // and advances no mutation milestone (#165).
54
+ if (!directMutationInWorkspace(payload, cwd, root)) return;
55
+ if (workflow.strategy === 'iterative-single') {
56
+ recordBudgetEvent(context, 'iteration', { evidence: `${tool}:direct-mutation` });
57
+ }
58
+ workflow.lastMutationAt = Date.now();
59
+ saveWorkflow(context);
60
+ return;
52
61
  }
53
- workflow.lastMutationAt = Date.now();
54
- saveWorkflow(context);
62
+
63
+ // Shell safety and shell mutation accounting are intentionally separate.
64
+ // `untrusted` means "not proven read-only", not "a mutation occurred".
65
+ // Capture an opaque pre-execution workspace fingerprint; the post-tool
66
+ // observer consumes an iteration only when workspace content actually changed.
67
+ const fingerprint = workspaceFingerprint(cwd);
68
+ const probeKey = shellProbeKey(payload, cwd);
69
+ registerMutationProbe(context, probeKey, fingerprint, workflow.strategy === 'iterative-single');
55
70
  }
56
71
 
57
72
  readHookInput(decide);
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { loadWorkflow, discardMutationProbe, readHookInput } = require('./lib/workflow-runtime');
5
+ const { cwdOf, shellProbeKey } = require('./lib/workflow-isolation');
6
+
7
+ function discardDenied(payload) {
8
+ if (!payload) return;
9
+ const tool = payload.tool_name || payload.tool;
10
+ if (tool !== 'Bash' && tool !== 'PowerShell' && tool !== 'exec_command') return;
11
+ const context = loadWorkflow(payload);
12
+ if (!context.workflow || context.workflow.state === 'deferred') return;
13
+ const cwd = cwdOf(payload, context.root);
14
+ discardMutationProbe(context, shellProbeKey(payload, cwd));
15
+ }
16
+
17
+ readHookInput(discardDenied);
@@ -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.11.5",
4
+ "version": "0.11.7",
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.11.5",
3
+ "version": "0.11.7",
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.11.5",
3
+ "version": "0.11.7",
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",
@@ -142,7 +142,7 @@
142
142
  "command": "node \"${PLUGIN_ROOT}/hooks/scripts/state-persist.js\"",
143
143
  "commandWindows": "node \"${PLUGIN_ROOT}\\hooks\\scripts\\state-persist.js\"",
144
144
  "timeout": 15,
145
- "statusMessage": "Saving Harness session state"
145
+ "statusMessage": "Saving Harness state and observed mutation"
146
146
  }
147
147
  ]
148
148
  },
@@ -1,13 +1,89 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
+ const crypto = require('crypto');
4
5
  const path = require('path');
5
6
  const { spawnSync } = require('child_process');
6
7
 
7
- function git(cwd, args) {
8
- const result = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, timeout: 5000 });
8
+ function gitRaw(cwd, args) {
9
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, timeout: 5000, maxBuffer: 16 * 1024 * 1024 });
9
10
  if (result.status !== 0) throw new Error('cannot verify Git worktree isolation');
10
- return result.stdout.trim();
11
+ return result.stdout;
12
+ }
13
+
14
+ function git(cwd, args) {
15
+ return gitRaw(cwd, args).trim();
16
+ }
17
+
18
+ function hashWorktreePath(root, relative) {
19
+ const target = path.join(root, relative);
20
+ let stat;
21
+ try { stat = fs.lstatSync(target); }
22
+ catch (error) {
23
+ if (error.code === 'ENOENT') return null;
24
+ throw error;
25
+ }
26
+ if (stat.isDirectory()) {
27
+ const result = spawnSync('git', ['-C', target, 'rev-parse', 'HEAD'], { encoding: 'utf8', windowsHide: true, timeout: 5000 });
28
+ return { mode: '160000', oid: result.status === 0 ? result.stdout.trim() : 'directory' };
29
+ }
30
+ const mode = stat.isSymbolicLink() ? '120000' : ((stat.mode & 0o111) ? '100755' : '100644');
31
+ const result = spawnSync('git', ['hash-object', '--path=' + relative, '--', relative], {
32
+ cwd: root, encoding: 'utf8', windowsHide: true, timeout: 5000,
33
+ });
34
+ if (result.status !== 0) throw new Error('cannot fingerprint workspace content');
35
+ return { mode, oid: result.stdout.trim() };
36
+ }
37
+
38
+ function workspaceFingerprint(cwd) {
39
+ const root = canonical(git(cwd, ['rev-parse', '--show-toplevel']));
40
+ const entries = new Map();
41
+ for (const record of gitRaw(root, ['ls-files', '-s', '-z']).split('\0')) {
42
+ if (!record) continue;
43
+ const tab = record.indexOf('\t');
44
+ if (tab < 0) throw new Error('cannot fingerprint workspace index');
45
+ const meta = record.slice(0, tab).trim().split(/\s+/);
46
+ const relative = record.slice(tab + 1);
47
+ const mode = meta[0];
48
+ const oid = meta[1];
49
+ entries.set(relative, { relative, mode, oid });
50
+ }
51
+
52
+ // Substitute current worktree content for tracked paths that differ from the
53
+ // index. This keeps the fingerprint stable across git add / git commit when
54
+ // visible workspace content did not change.
55
+ for (const relative of gitRaw(root, ['diff-files', '--name-only', '-z']).split('\0').filter(Boolean)) {
56
+ const value = hashWorktreePath(root, relative);
57
+ if (value) entries.set(relative, { relative, mode: value.mode, oid: value.oid });
58
+ else entries.delete(relative);
59
+ }
60
+
61
+ for (const relative of gitRaw(root, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean)) {
62
+ const value = hashWorktreePath(root, relative);
63
+ entries.set(relative, { relative, mode: value.mode, oid: value.oid });
64
+ }
65
+
66
+ const digest = crypto.createHash('sha256');
67
+ digest.update('harness-workspace-fingerprint-v1\0');
68
+ for (const entry of [...entries.values()].sort((a, b) => a.relative.localeCompare(b.relative))) {
69
+ digest.update(entry.relative); digest.update('\0');
70
+ digest.update(entry.mode); digest.update('\0');
71
+ digest.update(entry.oid); digest.update('\0');
72
+ }
73
+ return digest.digest('hex');
74
+ }
75
+
76
+ function shellProbeKey(payload, cwd) {
77
+ const toolUseId = String(payload?.tool_use_id || payload?.toolUseId || '').trim();
78
+ const tool = String(payload?.tool_name || payload?.tool || '');
79
+ const command = commandOf(payload);
80
+ return crypto.createHash('sha256')
81
+ .update('harness-shell-probe-v2\0')
82
+ .update(toolUseId || 'no-tool-use-id').update('\0')
83
+ .update(tool).update('\0')
84
+ .update(key(cwd)).update('\0')
85
+ .update(command)
86
+ .digest('hex');
11
87
  }
12
88
 
13
89
  function canonical(value) {
@@ -144,9 +220,9 @@ function splitShellSegments(text) {
144
220
 
145
221
  function classifyShell(command) {
146
222
  const text = String(command || '').trim();
147
- if (!text) return 'mutation-or-unknown';
223
+ if (!text) return 'untrusted';
148
224
  const segments = splitShellSegments(text);
149
- if (!segments) return 'mutation-or-unknown';
225
+ if (!segments) return 'untrusted';
150
226
  if (segments.length === 1) {
151
227
  if (/^git\s+worktree\s+add\s+/i.test(segments[0]) && !/\s--(?:force|checkout|detach)(?:\s|=|$)/i.test(segments[0])) return 'worktree-setup';
152
228
  return isReadOnlySegment(segments[0]) ? 'read-only' : 'mutation-or-unknown';
@@ -172,6 +248,35 @@ function mutationPaths(payload) {
172
248
  }
173
249
  return [input.file_path || input.filePath || input.path].filter(value => typeof value === 'string' && value.trim());
174
250
  }
251
+
252
+ // Bound workspace root plus its linked worktrees. Git failures (e.g. a
253
+ // non-git workspace) degrade to the root alone instead of throwing, so
254
+ // Tier-2 accounting never blocks on this enumeration (#165; cf. #162).
255
+ function worktreeRoots(root) {
256
+ const roots = [root];
257
+ try {
258
+ for (const field of git(root, ['worktree', 'list', '--porcelain', '-z']).split('\0')) {
259
+ if (field.startsWith('worktree ')) {
260
+ const candidate = field.slice('worktree '.length).trim();
261
+ if (candidate) roots.push(candidate);
262
+ }
263
+ }
264
+ } catch (_) { /* root alone */ }
265
+ return roots;
266
+ }
267
+
268
+ // Direct-tool (Edit/Write/apply_patch) targets count against the Tier-2
269
+ // budget only when at least one target lies inside the workspace (#165).
270
+ // Unknown targets (no parseable path) stay conservative and count.
271
+ function directMutationInWorkspace(payload, cwd, root) {
272
+ const targets = mutationPaths(payload);
273
+ if (!targets.length) return true;
274
+ const roots = worktreeRoots(root);
275
+ return targets.some(target => {
276
+ const resolved = path.resolve(cwd, target);
277
+ return roots.some(candidate => within(candidate, resolved));
278
+ });
279
+ }
175
280
  function assertTargets(payload, cwd, isolatedRoot) {
176
281
  const targets = mutationPaths(payload);
177
282
  if (!targets.length) throw new Error('mutation target is unknown; cannot verify worktree isolation');
@@ -198,4 +303,4 @@ function assertShellScope(command, cwd, isolatedRoot) {
198
303
  }
199
304
  }
200
305
 
201
- module.exports = { canonical, key, within, linkedWorktree, classifyShell, inputOf, cwdOf, commandOf, mutationPaths, assertTargets, assertShellScope };
306
+ module.exports = { canonical, key, within, linkedWorktree, classifyShell, workspaceFingerprint, shellProbeKey, inputOf, cwdOf, commandOf, mutationPaths, worktreeRoots, directMutationInWorkspace, assertTargets, assertShellScope };
@@ -43,6 +43,140 @@ function loadWorkflow(payload) {
43
43
 
44
44
  function saveWorkflow(context) { atomicWriteJson(context.file, context.workflow); }
45
45
 
46
+ const MUTATION_PROBE_KEY = /^[a-f0-9]{64}$/;
47
+ const MUTATION_PROBE_WAIT = new Int32Array(new SharedArrayBuffer(4));
48
+
49
+ function mutationProbeDir(context) {
50
+ return path.join(context.sessionDir, 'mutation-probes');
51
+ }
52
+
53
+ function mutationProbeFile(context, key) {
54
+ if (!MUTATION_PROBE_KEY.test(String(key || ''))) throw new Error('invalid mutation probe identity');
55
+ return path.join(mutationProbeDir(context), key + '.json');
56
+ }
57
+
58
+ function withMutationProbeLock(context, callback) {
59
+ fs.mkdirSync(context.sessionDir, { recursive: true });
60
+ const lockDir = path.join(context.sessionDir, '.mutation-probes.lock');
61
+ let acquired = false;
62
+ for (let attempt = 0; attempt < 200; attempt++) {
63
+ try {
64
+ fs.mkdirSync(lockDir);
65
+ acquired = true;
66
+ break;
67
+ } catch (error) {
68
+ if (error.code !== 'EEXIST') throw error;
69
+ try {
70
+ const stat = fs.statSync(lockDir);
71
+ if (Date.now() - stat.mtimeMs > 30000) {
72
+ fs.rmSync(lockDir, { recursive: true, force: true });
73
+ continue;
74
+ }
75
+ } catch (_) { /* retry below */ }
76
+ Atomics.wait(MUTATION_PROBE_WAIT, 0, 0, 10);
77
+ }
78
+ }
79
+ if (!acquired) throw new Error('cannot acquire mutation probe lock');
80
+ try { return callback(); }
81
+ finally { fs.rmSync(lockDir, { recursive: true, force: true }); }
82
+ }
83
+
84
+ function mutationProbeReservations(context) {
85
+ const dir = mutationProbeDir(context);
86
+ let files = [];
87
+ try { files = fs.readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)); }
88
+ catch (_) { return 0; }
89
+ let count = 0;
90
+ for (const file of files) {
91
+ const probe = readJson(path.join(dir, file));
92
+ if (probe?.schemaVersion === 1 && probe.reserveIteration === true) count++;
93
+ }
94
+ return count;
95
+ }
96
+
97
+ function refreshWorkflowForProbe(context) {
98
+ const latest = readJson(context.file);
99
+ if (!latest || latest.sessionId !== context.sessionId) throw new Error('cannot refresh workflow for mutation probe');
100
+ context.workflow = latest;
101
+ return latest;
102
+ }
103
+
104
+ function assertIterationCapacity(context, evidence) {
105
+ const { workflow } = context;
106
+ if (!workflow) throw budgetError('no workflow available for iteration capacity');
107
+ const budget = ensureWorkflowBudget(workflow);
108
+ if (budget.state !== 'active') throw budgetError(`workflow budget is ${budget.state}: ${budget.reasonCode || 'unavailable'}`);
109
+ const limit = budget.limits.maxIterations;
110
+ if (limit === null) return { applicable: false, value: budget.counters.iterations, reserved: 0 };
111
+ const reserved = mutationProbeReservations(context);
112
+ if (budget.counters.iterations + reserved >= limit) {
113
+ return exhaustBudget(context, 'iteration-budget-exhausted', evidence || 'untrusted-shell-capacity');
114
+ }
115
+ return { applicable: true, value: budget.counters.iterations, reserved, limit };
116
+ }
117
+
118
+ function registerMutationProbe(context, key, fingerprint, reserveIteration) {
119
+ if (!MUTATION_PROBE_KEY.test(String(key || '')) || !MUTATION_PROBE_KEY.test(String(fingerprint || ''))) {
120
+ throw new Error('invalid mutation probe identity');
121
+ }
122
+ return withMutationProbeLock(context, () => {
123
+ refreshWorkflowForProbe(context);
124
+ if (reserveIteration) assertIterationCapacity(context, 'shell:untrusted');
125
+ const dir = mutationProbeDir(context);
126
+ fs.mkdirSync(dir, { recursive: true });
127
+ const file = mutationProbeFile(context, key);
128
+ if (fs.existsSync(file)) throw new Error('mutation probe is already pending for this tool call');
129
+ const probe = {
130
+ schemaVersion: 1,
131
+ fingerprint,
132
+ reserveIteration: Boolean(reserveIteration),
133
+ observedAt: Date.now(),
134
+ };
135
+ atomicWriteJson(file, probe);
136
+ return probe;
137
+ });
138
+ }
139
+
140
+ function peekMutationProbe(context, key) {
141
+ return readJson(mutationProbeFile(context, key));
142
+ }
143
+
144
+ function settleMutationProbe(context, key, afterFingerprint, tool) {
145
+ if (!MUTATION_PROBE_KEY.test(String(afterFingerprint || ''))) throw new Error('invalid mutation probe fingerprint');
146
+ return withMutationProbeLock(context, () => {
147
+ const file = mutationProbeFile(context, key);
148
+ const probe = readJson(file);
149
+ if (!probe || probe.schemaVersion !== 1) return null;
150
+ refreshWorkflowForProbe(context);
151
+ fs.unlinkSync(file);
152
+ const changed = afterFingerprint !== probe.fingerprint;
153
+ if (changed) {
154
+ context.workflow.lastMutationAt = Date.now();
155
+ if (probe.reserveIteration && context.workflow.strategy === 'iterative-single') {
156
+ recordBudgetEvent(context, 'iteration', { evidence: String(tool || 'shell') + ':observed-workspace-mutation' });
157
+ } else {
158
+ saveWorkflow(context);
159
+ }
160
+ }
161
+ return { changed, probe };
162
+ });
163
+ }
164
+
165
+ function discardMutationProbe(context, key) {
166
+ return withMutationProbeLock(context, () => {
167
+ const file = mutationProbeFile(context, key);
168
+ if (!fs.existsSync(file)) return false;
169
+ fs.unlinkSync(file);
170
+ return true;
171
+ });
172
+ }
173
+
174
+ function clearMutationProbes(context) {
175
+ return withMutationProbeLock(context, () => {
176
+ fs.rmSync(mutationProbeDir(context), { recursive: true, force: true });
177
+ });
178
+ }
179
+
46
180
  function budgetLimits(workflow) {
47
181
  const limits = workflow?.workflowPlan?.limits || {};
48
182
  return {
@@ -123,7 +257,8 @@ function recordBudgetEvent(context, type, options = {}) {
123
257
  const consume = (counter, limitName, reasonCode) => {
124
258
  const limit = budget.limits[limitName];
125
259
  if (limit === null) return { applicable: false, value: budget.counters[counter] };
126
- if (budget.counters[counter] >= limit) return exhaustBudget(context, reasonCode, evidence);
260
+ const reserved = type === 'iteration' ? mutationProbeReservations(context) : 0;
261
+ if (budget.counters[counter] + reserved >= limit) return exhaustBudget(context, reasonCode, evidence);
127
262
  budget.counters[counter]++;
128
263
  appendBudgetEvent(budget, { type, counter, value: budget.counters[counter], limit, evidence, observedAt: now });
129
264
  persistBudget(context);
@@ -175,6 +310,7 @@ function resetWorkflowBudget(context, evidence) {
175
310
  evidence: String(evidence).trim(),
176
311
  resetAt: now,
177
312
  }].slice(-16);
313
+ clearMutationProbes(context);
178
314
  workflow.budget = {
179
315
  schemaVersion: 1,
180
316
  epoch: previous.epoch + 1,
@@ -260,4 +396,4 @@ function readHookInput(decide) {
260
396
  process.stdin.on('error', finish);
261
397
  }
262
398
 
263
- module.exports = { OPEN_STATES, SAFE_ID, WORKFLOW_CONTROLLER_COMMANDS, isMajorWorkflow, loadWorkflow, saveWorkflow, budgetLimits, ensureWorkflowBudget, recordBudgetEvent, resetWorkflowBudget, syncBudgetToRun, matchingRun, unresolvedStages, readHookInput };
399
+ module.exports = { OPEN_STATES, SAFE_ID, WORKFLOW_CONTROLLER_COMMANDS, isMajorWorkflow, loadWorkflow, saveWorkflow, budgetLimits, ensureWorkflowBudget, mutationProbeReservations, assertIterationCapacity, registerMutationProbe, peekMutationProbe, settleMutationProbe, discardMutationProbe, clearMutationProbes, recordBudgetEvent, resetWorkflowBudget, syncBudgetToRun, matchingRun, unresolvedStages, readHookInput };
@@ -17,6 +17,7 @@ process.stdin.on('end', () => {
17
17
  const payload = JSON.parse(inputData);
18
18
  const hookEventName = payload.hook_event_name || payload.hookEventName || '';
19
19
  const isFailureEvent = hookEventName === 'PostToolUseFailure';
20
+ const isSuccessEvent = hookEventName === 'PostToolUse';
20
21
  const toolResponse = payload.tool_response || {};
21
22
  const stdout = toolResponse.stdout ?? payload.stdout ?? '';
22
23
  const stderr = toolResponse.stderr ?? payload.stderr ?? '';
@@ -25,10 +26,10 @@ process.stdin.on('end', () => {
25
26
  const exitCode = typeof rawExitCode === 'number' ? rawExitCode : undefined;
26
27
 
27
28
  const explicitFailure = exitCode !== undefined && exitCode !== 0;
28
- const explicitSuccess = exitCode === 0;
29
29
  const stderrSignal = typeof stderr === 'string' && stderr.trim().length > 0;
30
30
  const looksLikeError = /\b(error|fail|failed|failure|exception|fatal|panic|traceback|denied|refused|cannot|unable)\b/i.test(stderr);
31
- const isFailure = isFailureEvent || explicitFailure || (exitCode === undefined && stderrSignal && looksLikeError);
31
+ const isFailure = isFailureEvent || explicitFailure ||
32
+ (!isSuccessEvent && exitCode === undefined && stderrSignal && looksLikeError);
32
33
  const errorText = isFailureEvent
33
34
  ? (failureText || (stderrSignal ? stderr : stdout) || '')
34
35
  : ((stderrSignal ? stderr : stdout) || failureText || '');
@@ -47,19 +48,22 @@ process.stdin.on('end', () => {
47
48
  const filePath = payload.tool_input?.file_path || payload.tool_input?.filePath || '';
48
49
  const command = payload.tool_input?.command || '';
49
50
 
50
- // Categorize failure type for context-aware thresholds
51
+ // Categorize failure type for context-aware thresholds.
52
+ // NOTE (#167): match anchored diagnostic phrases, not bare substrings.
53
+ // A bare `denied`/`timeout` substring matches file names such as
54
+ // `workflow-mutation-denied.js` and misclassifies the failure.
51
55
  let category = 'unknown';
52
- if (/SyntaxError|Unexpected token|Syntax Error/i.test(errorText)) {
56
+ if (/\bSyntaxError\b|Unexpected token|Syntax Error/i.test(errorText)) {
53
57
  category = 'syntax';
54
- } else if (/ENOENT|not found|No such file|找不到|不存在/i.test(errorText)) {
58
+ } else if (/\bENOENT\b|\bnot found\b|No such file|找不到|不存在/i.test(errorText)) {
55
59
  category = 'environment';
56
- } else if (/timeout|timed out|超時|逾時/i.test(errorText)) {
60
+ } else if (/\bETIMEDOUT\b|TimeoutError|\btimeout\b|\btimed out\b|\btiming out\b|超時|逾時/i.test(errorText)) {
57
61
  category = 'timeout';
58
62
  } else if (/test.*fail|assertion|AssertionError|測試.*失敗|斷言.*錯誤/i.test(errorText)) {
59
63
  category = 'test';
60
- } else if (/permission|denied|EACCES|權限/i.test(errorText)) {
64
+ } else if (/\bpermission denied\b|\bEACCES\b|\bEPERM\b|權限.*(拒絕|不足|被拒)|拒絕.*權限/i.test(errorText)) {
61
65
  category = 'permission';
62
- } else if (/dependency|module not found|Cannot find module|模組.*找不到/i.test(errorText)) {
66
+ } else if (/\bdependency\b|\bmodule not found\b|Cannot find module|模組.*找不到/i.test(errorText)) {
63
67
  category = 'dependency';
64
68
  }
65
69
 
@@ -114,7 +118,11 @@ process.stdin.on('end', () => {
114
118
  state.lastFailureAt = Date.now();
115
119
 
116
120
  fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
117
- } else if (!isFailureEvent && explicitSuccess) {
121
+ } else if (!isFailure) {
122
+ // NOTE (#166, follows #153): Claude Code PostToolUse success payloads
123
+ // carry no numeric exit code. The lifecycle event is authoritative:
124
+ // PostToolUse is success unless a numeric non-zero status contradicts it;
125
+ // hosts without lifecycle event names keep the conservative stderr fallback.
118
126
  // A successful action after an accepted zoom-out is an objective recovery
119
127
  // boundary. Emit only IDs/hashes/category; never copy command/output text.
120
128
  if ((state.zoomOutCycles || 0) > 0 && state.lastHash) {