harness-everything 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +2 -2
  4. package/CHANGELOG.md +8 -0
  5. package/README.md +7 -7
  6. package/VERIFICATION.md +3 -3
  7. package/ci/invariant-routing.test.js +58 -158
  8. package/ci/mechanism-2a-rule-of-3.test.js +73 -138
  9. package/ci/mechanism-2ac-atomic-commit-check.test.js +2 -2
  10. package/ci/mechanism-2af-worktree-isolation.test.js +38 -196
  11. package/ci/mechanism-2ah-workflow-lifecycle.test.js +20 -831
  12. package/ci/mechanism-2ai-workspace-fingerprint-batch.test.js +3 -4
  13. package/ci/mechanism-2b-boundary-guard.test.js +6 -16
  14. package/ci/mechanism-2e-scope-guard.test.js +8 -24
  15. package/ci/mechanism-2f-stop-gate.test.js +21 -75
  16. package/ci/mechanism-2n-opencode-plugin.test.js +13 -28
  17. package/ci/openai-plugin-package.test.js +8 -8
  18. package/ci/openai-plugin-runtime.test.js +2 -2
  19. package/ci/router-workflow-plan.test.js +6 -6
  20. package/ci/self-evolve-loop.test.js +4 -1
  21. package/ci/workflow-execution-contract.test.js +51 -129
  22. package/ci/workflow-plan-consumers-edge.test.js +1 -1
  23. package/ci/workflow-plan-consumers.test.js +6 -6
  24. package/docs/architecture.md +21 -21
  25. package/docs/enforcement-locks.md +41 -102
  26. package/docs/mechanism-first-skill-mesh.md +2 -2
  27. package/docs/philosophy.md +2 -2
  28. package/docs/platform-capabilities.md +3 -3
  29. package/docs/routing.md +14 -14
  30. package/docs/workflow-runtime.md +80 -110
  31. package/evals/harness-everything/tasks/positive-tier1.yaml +1 -1
  32. package/evals/harness-everything/tasks/positive-tier2.yaml +1 -1
  33. package/evals/harness-everything/tasks/positive-tier3.yaml +1 -1
  34. package/evals/using-git-worktrees/tasks/positive-trigger.yaml +1 -1
  35. package/fable-mode/SKILL.md +1 -1
  36. package/fable-mode/execution-guardrails/SKILL.md +1 -1
  37. package/fable-mode/fable-haiku/SKILL.md +1 -1
  38. package/fable-mode/fable-opus/SKILL.md +1 -1
  39. package/fable-mode/fable-sonnet/SKILL.md +1 -1
  40. package/fable-mode/scripts/workflow-plan-consumer.js +4 -3
  41. package/harness-everything/SKILL.md +11 -11
  42. package/harness-everything/references/triage-and-tiers.md +11 -11
  43. package/harness-everything/scripts/kernel-router-core.js +5 -5
  44. package/harness-everything/scripts/kernel-router.js +15 -17
  45. package/harness-everything/scripts/router-contract-core.js +1 -1
  46. package/hooks/hooks.json +10 -23
  47. package/hooks/scripts/atomic-commit-check.js +1 -1
  48. package/hooks/scripts/boundary-guard.js +4 -4
  49. package/hooks/scripts/contract-test.js +8 -11
  50. package/hooks/scripts/depth-guard.js +2 -2
  51. package/hooks/scripts/lib/workflow-runtime.js +24 -402
  52. package/hooks/scripts/rule-of-3-tracker.js +28 -31
  53. package/hooks/scripts/rule-of-3.js +4 -28
  54. package/hooks/scripts/state-persist.js +36 -136
  55. package/hooks/scripts/stop-gate.js +15 -48
  56. package/hooks/scripts/subagent-scope-guard.js +8 -8
  57. package/hooks/scripts/workflow-disposition.js +2 -10
  58. package/hooks/scripts/workflow-gate.js +33 -84
  59. package/hooks/scripts/workflow-mutation-denied.js +3 -15
  60. package/hooks/scripts/workflow-stop-gate.js +23 -38
  61. package/opencode-plugin/README.md +9 -10
  62. package/opencode-plugin/index.mjs +14 -30
  63. package/opencode-plugin/plugin.json +1 -1
  64. package/package.json +1 -1
  65. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  66. package/plugins/harness-everything/hooks/hooks.json +3 -3
  67. package/plugins/harness-everything/hooks/scripts/atomic-commit-check.js +1 -1
  68. package/plugins/harness-everything/hooks/scripts/boundary-guard.js +4 -4
  69. package/plugins/harness-everything/hooks/scripts/contract-test.js +8 -11
  70. package/plugins/harness-everything/hooks/scripts/depth-guard.js +2 -2
  71. package/plugins/harness-everything/hooks/scripts/lib/workflow-runtime.js +24 -402
  72. package/plugins/harness-everything/hooks/scripts/rule-of-3-tracker.js +28 -31
  73. package/plugins/harness-everything/hooks/scripts/rule-of-3.js +4 -28
  74. package/plugins/harness-everything/hooks/scripts/state-persist.js +36 -136
  75. package/plugins/harness-everything/hooks/scripts/stop-gate.js +15 -48
  76. package/plugins/harness-everything/hooks/scripts/subagent-scope-guard.js +8 -8
  77. package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +2 -10
  78. package/plugins/harness-everything/hooks/scripts/workflow-gate.js +33 -84
  79. package/plugins/harness-everything/hooks/scripts/workflow-mutation-denied.js +3 -15
  80. package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +23 -38
  81. package/plugins/harness-everything/plugin.json +1 -1
  82. package/plugins/harness-everything/scripts/lib/advisory-text.js +16 -29
  83. package/plugins/harness-everything/skills/fable-mode/SKILL.md +1 -1
  84. package/plugins/harness-everything/skills/fable-mode/execution-guardrails/SKILL.md +1 -1
  85. package/plugins/harness-everything/skills/fable-mode/fable-haiku/SKILL.md +1 -1
  86. package/plugins/harness-everything/skills/fable-mode/fable-opus/SKILL.md +1 -1
  87. package/plugins/harness-everything/skills/fable-mode/fable-sonnet/SKILL.md +1 -1
  88. package/plugins/harness-everything/skills/fable-mode/scripts/workflow-plan-consumer.js +4 -3
  89. package/plugins/harness-everything/skills/harness-everything/SKILL.md +11 -11
  90. package/plugins/harness-everything/skills/harness-everything/references/triage-and-tiers.md +11 -11
  91. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router-core.js +5 -5
  92. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router.js +15 -17
  93. package/plugins/harness-everything/skills/harness-everything/scripts/router-contract-core.js +1 -1
  94. package/plugins/harness-everything/skills/using-git-worktrees/SKILL.md +5 -5
  95. package/scripts/lib/advisory-text.js +16 -29
  96. package/using-git-worktrees/SKILL.md +5 -5
@@ -2,151 +2,51 @@
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, isMajorWorkflow, peekMutationProbe, settleMutationProbe, settleMutationProbeConservative } = require('./lib/workflow-runtime');
6
- const { cwdOf, shellProbeKey, isVerificationShell, workspaceFingerprint, directMutationInWorkspace } = require('./lib/workflow-isolation');
5
+ const { cwdOf, classifyShell, isVerificationShell, directMutationInWorkspace } = require('./lib/workflow-isolation');
7
6
  const { observeTool } = require('./lib/telemetry');
8
-
9
7
  let inputData = '';
10
- const timeout = setTimeout(() => {
11
- processState(null);
12
- }, 200);
13
-
14
- process.stdin.on('data', chunk => {
15
- inputData += chunk;
16
- });
17
-
18
- process.stdin.on('end', () => {
19
- clearTimeout(timeout);
20
- try {
21
- const payload = JSON.parse(inputData.trim());
22
- processState(payload);
23
- } catch (err) {
24
- processState(null);
25
- }
26
- });
27
-
28
- function observeWorkspaceMutation(payload, root) {
29
- const toolName = payload && (payload.tool_name || payload.tool);
30
- if (toolName !== 'Bash' && toolName !== 'PowerShell' && toolName !== 'exec_command') return false;
31
- const context = loadWorkflow(payload);
32
- if (!context.workflow || context.workflow.state === 'deferred') return false;
33
- const cwd = cwdOf(payload, root);
34
- const probeKey = shellProbeKey(payload, cwd);
35
- const probe = peekMutationProbe(context, probeKey);
36
- if (!probe) return false;
37
- const observationCwd = probe.observationCwd || cwd;
38
- let after;
39
- try {
40
- after = workspaceFingerprint(observationCwd);
41
- } catch (error) {
42
- const fingerprintLimited = error && error.code === 'HARNESS_FINGERPRINT_LIMIT';
43
- if (!isMajorWorkflow(context.workflow) || fingerprintLimited) {
44
- const evidence = fingerprintLimited ? 'fingerprint-limit' : 'unobservable-workspace';
45
- const conservative = settleMutationProbeConservative(context, probeKey, toolName, evidence);
46
- return Boolean(conservative && conservative.changed);
47
- }
48
- context.workflow.state = 'blocked';
49
- context.workflow.blockReason = 'mutation-observation-failed';
50
- saveWorkflow(context);
51
- const critical = new Error('cannot verify whether shell execution changed workspace content');
52
- critical.code = 'HARNESS_MUTATION_OBSERVATION';
53
- throw critical;
54
- }
55
- const result = settleMutationProbe(context, probeKey, after, toolName);
56
- return Boolean(result && result.changed);
57
- }
58
-
8
+ const timeout = setTimeout(() => processState(null), 200);
9
+ process.stdin.on('data', chunk => { inputData += chunk; });
10
+ process.stdin.on('end', () => { clearTimeout(timeout); try { processState(JSON.parse(inputData.trim())); } catch (_) { processState(null); } });
59
11
  function processState(payload) {
60
- let exitCodeForHook = 0;
61
12
  try {
62
13
  const sessionId = payload && (payload.session_id || payload.sessionId);
63
14
  const root = getWorkspaceRoot(payload);
64
15
  const stateFile = path.join(getSessionDir(root, sessionId), 'handoff-state.json');
65
-
66
- // Merge into the existing state rather than replacing it, so the
67
- // failure/idle status and the Stop-gate milestones can coexist.
68
16
  let state = { status: 'idle', timestamp: new Date().toISOString() };
69
- if (fs.existsSync(stateFile)) {
70
- try { state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); } catch (e) { /* keep default */ }
17
+ if (fs.existsSync(stateFile)) { try { state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); } catch (_) {} }
18
+ if (!payload) {
19
+ if (!fs.existsSync(stateFile)) fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
20
+ process.exit(0);
71
21
  }
72
-
73
- if (payload) {
74
- // Hook hosts nest tool output under tool_response, not at the top
75
- // level, and uses tool_name rather than tool. A numeric exit code
76
- // also isn't guaranteed to be present at all - only trust it when
77
- // it's actually a number; fall back to a stderr signal otherwise.
78
- const toolResponse = payload.tool_response || {};
79
- const stdout = toolResponse.stdout ?? payload.stdout ?? '';
80
- const stderr = toolResponse.stderr ?? payload.stderr ?? payload.error ?? '';
81
- const rawExitCode = toolResponse.exitCode ?? toolResponse.exit_code ?? payload.exitCode;
82
- const exitCode = typeof rawExitCode === 'number' ? rawExitCode : undefined;
83
- const stderrSignal = typeof stderr === 'string' && stderr.trim().length > 0;
84
- const hookEvent = payload.hook_event_name || payload.hookEventName || '';
85
- const isSuccessEvent = hookEvent === 'PostToolUse';
86
- const isFailed = hookEvent === 'PostToolUseFailure' ||
87
- (exitCode !== undefined && exitCode !== 0) ||
88
- (!isSuccessEvent && exitCode === undefined && stderrSignal);
89
- const toolName = payload.tool_name || payload.tool || 'command';
90
- const observedMutation = observeWorkspaceMutation(payload, root);
91
-
92
- if (isFailed) {
93
- // Truncate output to avoid state bloat
94
- let output = (stderrSignal ? stderr : stdout) || payload.output || '';
95
- if (output.length > 500) {
96
- output = '...' + output.slice(-500);
97
- }
98
-
99
- state.status = 'failed';
100
- state.timestamp = new Date().toISOString();
101
- state.tool = toolName;
102
- state.exitCode = exitCode;
103
- state.errorSummary = output.trim();
104
- } else if (!isFailed && state.status === 'failed') {
105
- // Clear failed state or mark as idle/resolved.
106
- // NOTE (#166, follows #153): Claude Code Bash PostToolUse payloads
107
- // carry no numeric exit code, so `exitCode === 0` never holds there.
108
- // A PostToolUse event with no numeric non-zero exit status counts as
109
- // success even when stderr contains diagnostics. Hosts that omit the
110
- // lifecycle event name retain the conservative stderr fallback.
111
- state.lastResolved = { tool: state.tool, timestamp: state.timestamp };
112
- state.status = 'idle';
113
- state.timestamp = new Date().toISOString();
114
- delete state.exitCode;
115
- delete state.errorSummary;
116
- }
117
-
118
- // Milestones for the Stop gate: when did the last mutation happen, and
119
- // has any verification-ish command succeeded since. Direct-tool edits
120
- // outside the workspace advance neither milestone (#165).
121
- const isDirectTool = toolName === 'Edit' || toolName === 'Write' || toolName === 'apply_patch';
122
- if ((isDirectTool && directMutationInWorkspace(payload, cwdOf(payload, root), root)) || observedMutation) {
123
- state.lastEditAt = Date.now();
124
- }
125
- if ((toolName === 'Bash' || toolName === 'PowerShell') && !isFailed) {
126
- const command = (payload.tool_input && payload.tool_input.command) || '';
127
- if (isVerificationShell(command)) {
128
- state.lastVerifyAt = Date.now();
129
- state.lastVerifyExitCode = exitCode ?? null;
130
- }
131
- }
132
-
133
- fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
134
- // Reuse this already-running post-tool hook instead of spawning another
135
- // process for every tool solely for telemetry. The observer is fail-open
136
- // and stores no command/args/output content.
137
- observeTool(payload);
138
- } else {
139
- // No standard input payload, or invalid JSON. Just maintain a heartbeat timestamp
140
- if (!fs.existsSync(stateFile)) {
141
- fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
142
- }
22
+ const toolResponse = payload.tool_response || {};
23
+ const stdout = toolResponse.stdout ?? payload.stdout ?? '';
24
+ const stderr = toolResponse.stderr ?? payload.stderr ?? payload.error ?? '';
25
+ const rawExitCode = toolResponse.exitCode ?? toolResponse.exit_code ?? payload.exitCode;
26
+ const exitCode = typeof rawExitCode === 'number' ? rawExitCode : undefined;
27
+ const stderrSignal = typeof stderr === 'string' && stderr.trim().length > 0;
28
+ const hookEvent = payload.hook_event_name || payload.hookEventName || '';
29
+ const isSuccessEvent = hookEvent === 'PostToolUse';
30
+ const isFailed = hookEvent === 'PostToolUseFailure' || (exitCode !== undefined && exitCode !== 0) || (!isSuccessEvent && exitCode === undefined && stderrSignal);
31
+ const toolName = payload.tool_name || payload.tool || 'command';
32
+ if (isFailed) {
33
+ let output = (stderrSignal ? stderr : stdout) || payload.output || '';
34
+ if (output.length > 500) output = '...' + output.slice(-500);
35
+ state.status = 'failed'; state.timestamp = new Date().toISOString(); state.tool = toolName; state.exitCode = exitCode; state.errorSummary = output.trim();
36
+ } else if (state.status === 'failed') {
37
+ state.lastResolved = { tool: state.tool, timestamp: state.timestamp }; state.status = 'idle'; state.timestamp = new Date().toISOString(); delete state.exitCode; delete state.errorSummary;
143
38
  }
144
- } catch (err) {
145
- if (err && (err.code === 'HARNESS_MUTATION_OBSERVATION' || err.code === 'HARNESS_WORKFLOW_BUDGET')) {
146
- console.error('[State Persist] ' + err.message);
147
- exitCodeForHook = 2;
39
+ const isDirectTool = toolName === 'Edit' || toolName === 'Write' || toolName === 'apply_patch';
40
+ if (isDirectTool && directMutationInWorkspace(payload, cwdOf(payload, root), root)) state.lastEditAt = Date.now();
41
+ const isShell = toolName === 'Bash' || toolName === 'PowerShell' || toolName === 'exec_command';
42
+ const command = (payload.tool_input && payload.tool_input.command) || '';
43
+ if (isShell) {
44
+ const commandClass = classifyShell(command);
45
+ if (commandClass !== 'read-only' && commandClass !== 'worktree-setup') state.lastEditAt = Date.now();
46
+ if (!isFailed && isVerificationShell(command)) { state.lastVerifyAt = Date.now(); state.lastVerifyExitCode = exitCode ?? null; }
148
47
  }
149
- // Non-critical persistence/telemetry failures remain fail-open.
150
- }
151
- process.exit(exitCodeForHook);
48
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
49
+ observeTool(payload);
50
+ } catch (_) {}
51
+ process.exit(0);
152
52
  }
@@ -1,12 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Stop Gate (Stop hook - Claude Code only)
4
- * The pre-delivery gate (verification-loop) was prose-only, and prose decays
5
- * as context grows. This makes the one check that matters mechanical: ending
6
- * the turn with edits that were never followed by a successful
7
- * verification-ish command (test/build/lint - tracked by state-persist.js)
8
- * while uncommitted changes sit in the tree gets bounced back ONCE per edit
9
- * batch. Objective facts only; fails open on any error.
3
+ * Verification reminder for Stop hooks.
4
+ *
5
+ * If uncommitted edits exist and no later successful verification was observed,
6
+ * emit one concise reminder for this Stop event. No gate state is persisted and
7
+ * Stop is never rejected.
10
8
  */
11
9
  const fs = require('fs');
12
10
  const path = require('path');
@@ -15,66 +13,35 @@ const { getWorkspaceRoot, getSessionDir } = require('./lib/harness-state');
15
13
 
16
14
  function decide(payload) {
17
15
  try {
18
- // Loop guard: if this stop already resulted from a Stop-hook block,
19
- // always let it through - one bounce is the contract, never a cage.
20
- if (payload && payload.stop_hook_active) {
21
- process.exit(0);
22
- }
23
-
24
16
  const root = getWorkspaceRoot(payload);
25
17
  const sessionDir = getSessionDir(root, payload && (payload.session_id || payload.sessionId));
26
18
  const handoffFile = path.join(sessionDir, 'handoff-state.json');
27
- const gateFile = path.join(sessionDir, 'stop-gate-state.json');
19
+ if (!fs.existsSync(handoffFile)) return;
28
20
 
29
- if (!fs.existsSync(handoffFile)) process.exit(0);
30
21
  const handoff = JSON.parse(fs.readFileSync(handoffFile, 'utf8'));
31
22
  const lastEditAt = handoff.lastEditAt || 0;
32
23
  const lastVerifyAt = handoff.lastVerifyAt || 0;
24
+ if (!lastEditAt || lastVerifyAt >= lastEditAt) return;
33
25
 
34
- // No edits this session, or verification already ran after the last edit.
35
- if (!lastEditAt || lastVerifyAt >= lastEditAt) process.exit(0);
36
-
37
- // One bounce per edit batch: if we already blocked for this exact edit
38
- // timestamp, the model either verified (which moves lastVerifyAt) or
39
- // explicitly chose not to - don't nag twice for the same batch.
40
- let gate = {};
41
- if (fs.existsSync(gateFile)) {
42
- try { gate = JSON.parse(fs.readFileSync(gateFile, 'utf8')); } catch (e) { /* ignore */ }
43
- }
44
- if (gate.blockedForEditAt === lastEditAt) process.exit(0);
45
-
46
- // Only relevant when unverified work is actually sitting in the tree.
47
26
  const dirty = execSync('git status --porcelain', {
48
27
  cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']
49
28
  }).trim();
50
- if (!dirty) process.exit(0);
51
-
52
- fs.writeFileSync(gateFile, JSON.stringify({ blockedForEditAt: lastEditAt }, null, 2), 'utf8');
29
+ if (!dirty) return;
53
30
 
54
- console.error(`[Stop Gate] Turn is ending with uncommitted edits and no successful verification command (test/build/lint) since the last edit.`);
55
- console.error(`Before delivering: run the relevant check from 'verification-loop' - even a single targeted test beats none.`);
56
- console.error(`If verification is genuinely not applicable (docs-only change, analysis-only turn, the Human Partner told you to stop), state that explicitly in your reply and stop again.`);
57
- console.error(`This gate bounces at most once per edit batch.`);
58
- process.exit(2);
59
- } catch (err) {
60
- // Fail open - a broken gate must never trap the session.
61
- process.exit(0);
31
+ console.error('[Verification Reminder] Uncommitted edits have no observed successful verification after the latest edit.');
32
+ console.error("Run a relevant test/build/lint check before claiming completion, or state why verification is not applicable.");
33
+ } catch (_) {
34
+ // Reminder infrastructure is always fail-open.
62
35
  }
63
36
  }
64
37
 
65
38
  let inputData = '';
66
- const timeout = setTimeout(() => {
67
- decide(null);
68
- }, 200);
69
-
39
+ const timeout = setTimeout(() => decide(null), 200);
70
40
  process.stdin.on('data', chunk => { inputData += chunk; });
71
41
  process.stdin.on('end', () => {
72
42
  clearTimeout(timeout);
73
43
  let payload = null;
74
- try { payload = JSON.parse(inputData.trim()); } catch (err) { /* no payload */ }
44
+ try { payload = JSON.parse(inputData.trim()); } catch (_) {}
75
45
  decide(payload);
76
46
  });
77
- process.stdin.on('error', () => {
78
- clearTimeout(timeout);
79
- decide(null);
80
- });
47
+ process.stdin.on('error', () => { clearTimeout(timeout); decide(null); });
@@ -161,20 +161,20 @@ process.stdin.on('end', () => {
161
161
  atomicWriteJson(path.join(sessionDir, 'subagent-scope-last.json'), audit);
162
162
 
163
163
  if (newlyChanged.length === 0) {
164
- console.log('[Subagent Scope Guard] No new changed files detected during the subagent burst.');
164
+ console.log('[Subagent Scope Reminder] No new changed files detected during the subagent burst.');
165
165
  } else if (declaredContracts.length === 0) {
166
- console.error(`[Subagent Scope Guard] ${newlyChanged.length} file(s) changed with no machine-readable writeSet contract:`);
166
+ console.error(`[Subagent Scope Reminder] ${newlyChanged.length} file(s) changed with no machine-readable writeSet contract:`);
167
167
  newlyChanged.forEach(line => console.error(` ${line}`));
168
168
  console.error('Confirm every path was actually in scope before trusting or committing this output.');
169
169
  } else {
170
170
  for (const entry of classified.expected) {
171
- console.log(`[Subagent Scope Guard] in-scope ${entry.statusLine} -> ${entry.planId}/${entry.runId}/${entry.stageId}${entry.workerId ? ` worker=${entry.workerId}` : ''}`);
171
+ console.log(`[Subagent Scope Reminder] in-scope ${entry.statusLine} -> ${entry.planId}/${entry.runId}/${entry.stageId}${entry.workerId ? ` worker=${entry.workerId}` : ''}`);
172
172
  }
173
173
  for (const entry of classified.ambiguous) {
174
- console.error(`[Subagent Scope Guard] ambiguous scope ${entry.statusLine}; matches ${entry.stages.map(stage => `${stage.runId}/${stage.stageId}`).join(', ')}`);
174
+ console.error(`[Subagent Scope Reminder] ambiguous scope ${entry.statusLine}; matches ${entry.stages.map(stage => `${stage.runId}/${stage.stageId}`).join(', ')}`);
175
175
  }
176
176
  for (const entry of classified.outOfScope) {
177
- console.error(`[Subagent Scope Guard] OUT-OF-SCOPE ${entry.statusLine}${entry.workerId ? ` worker=${entry.workerId}` : ''}`);
177
+ console.error(`[Subagent Scope Reminder] OUT-OF-SCOPE ${entry.statusLine}${entry.workerId ? ` worker=${entry.workerId}` : ''}`);
178
178
  }
179
179
  }
180
180
 
@@ -189,7 +189,7 @@ process.stdin.on('end', () => {
189
189
  : classified.ambiguous.length > 0 || classified.outOfScope.length > 0;
190
190
  if (violated) {
191
191
  console.error('Do not "git add -A"; stage files explicitly so unexpected paths stay visible.');
192
- process.exit(2);
192
+ process.exit(0);
193
193
  }
194
194
  process.exit(0);
195
195
  }
@@ -197,8 +197,8 @@ process.stdin.on('end', () => {
197
197
  process.exit(0);
198
198
  } catch (error) {
199
199
  if (error && error.code === 'HARNESS_WORKFLOW_BUDGET') {
200
- console.error(`[Subagent Scope Guard] ${error.message}`);
201
- process.exit(2);
200
+ console.error(`[Subagent Scope Reminder] ${error.message}`);
201
+ process.exit(0);
202
202
  }
203
203
  // This is otherwise a post-execution visibility gate. Runtime errors fail open;
204
204
  // actionGate has a stricter fail-ask policy for side effects.
@@ -3,7 +3,7 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
- const { loadWorkflow, saveWorkflow, matchingRun, OPEN_STATES, budgetLimits, ensureWorkflowBudget, recordBudgetEvent, resetWorkflowBudget, syncBudgetToRun, WORKFLOW_CONTROLLER_COMMANDS } = require('./lib/workflow-runtime');
6
+ const { loadWorkflow, saveWorkflow, matchingRun, OPEN_STATES, WORKFLOW_CONTROLLER_COMMANDS } = require('./lib/workflow-runtime');
7
7
  const { getWorkspaceRoot, readCurrentSession } = require('./lib/harness-state');
8
8
  const { atomicWriteJson, readJson } = require('./lib/fable-contracts');
9
9
 
@@ -41,9 +41,6 @@ function activatePlan(workflow, plan) {
41
41
  workflow.suggestedSkills = plan.suggestedSkills || [];
42
42
  workflow.verification = plan.verification;
43
43
  workflow.mutationIsolation = plan.mutationIsolation || { required: false };
44
- const budget = ensureWorkflowBudget(workflow);
45
- budget.limits = budgetLimits(workflow);
46
- budget.updatedAt = new Date().toISOString();
47
44
  }
48
45
 
49
46
  function main() {
@@ -57,8 +54,6 @@ function main() {
57
54
  if (args.command === 'start') {
58
55
  if (!workflow.workflowId || !workflow.workflowPlan) throw new Error('legacy workflow lacks a correlated plan; cannot start it implicitly');
59
56
  if (workflow.state === 'running') throw new Error('workflow already running; record a blocker before replanning');
60
- const isReplan = workflow.revision > 0 || Boolean(workflow.runId);
61
- if (isReplan) recordBudgetEvent(context, 'replan', { evidence: 'workflow-disposition:start' });
62
57
  const plan = workflow.pendingPlan || workflow.workflowPlan;
63
58
  if (!String(plan.strategy || '').startsWith('fable-')) {
64
59
  activatePlan(workflow, plan);
@@ -90,9 +85,7 @@ function main() {
90
85
  delete workflow.pendingPlan;
91
86
  delete workflow.blockReason;
92
87
  } else if (args.command === 'revision') {
93
- recordBudgetEvent(context, 'revision', { evidence: args.evidence || 'workflow-disposition:revision' });
94
- } else if (args.command === 'reset-budget') {
95
- resetWorkflowBudget(context, args.evidence);
88
+ console.error('[Workflow Reminder] Revision requested; no hard revision budget is enforced.');
96
89
  } else if (args.command === 'escape') {
97
90
  if (!ALLOWED_ESCAPE_REASONS.has(args.reasonCode)) throw new Error('generic simple/routine/already-clear reasons are intentionally rejected');
98
91
  if (!args.scope?.trim()) throw new Error('--scope is required');
@@ -114,7 +107,6 @@ function main() {
114
107
  workflow.blockReason = args.evidence.trim();
115
108
  } else throw new Error(usage());
116
109
  saveWorkflow(context);
117
- syncBudgetToRun(context);
118
110
  process.stdout.write(JSON.stringify({ state: workflow.state, workflowId: workflow.workflowId, runId: workflow.runId, revision: workflow.revision, escapes: workflow.escapes }) + '\n');
119
111
  }
120
112
  try { main(); } catch (error) { console.error('[Workflow Disposition] ' + error.message); process.exitCode = 2; }
@@ -1,101 +1,50 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
-
4
3
  const path = require('path');
5
4
  const fs = require('fs');
6
- const { loadWorkflow, saveWorkflow, isMajorWorkflow, matchingRun, registerMutationProbe, recordBudgetEvent, readHookInput, WORKFLOW_CONTROLLER_COMMANDS } = require('./lib/workflow-runtime');
7
- const { key, classifyShell, isVerificationShell, workspaceFingerprint, shellProbeKey, cwdOf, commandOf, mutationPaths, directMutationInWorkspace, linkedWorktree, assertTargets, assertShellScope } = require('./lib/workflow-isolation');
8
-
5
+ const { loadWorkflow, saveWorkflow, isMajorWorkflow, matchingRun, readHookInput, WORKFLOW_CONTROLLER_COMMANDS } = require('./lib/workflow-runtime');
6
+ const { key, classifyShell, cwdOf, commandOf, mutationPaths, directMutationInWorkspace, linkedWorktree, assertTargets, assertShellScope } = require('./lib/workflow-isolation');
9
7
  const DIRECT = new Set(['Edit', 'Write', 'apply_patch']);
10
8
  const SHELL = new Set(['Bash', 'PowerShell', 'exec_command']);
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
-
9
+ function warn(message) { console.error('[Workflow Reminder] ' + message); }
21
10
  function isController(command, cwd) {
22
- // One trusted local executable, literal arguments, no shell evaluation.
23
- if (/[;&|`\r\n<>$(){}]/.test(command)) return false;
11
+ if (/[;&|\x60\r\n<>$(){}]/.test(command)) return false;
24
12
  const match = command.match(/^node\s+(?:"([^"]+)"|'([^']+)'|(\S+))\s+([A-Za-z][A-Za-z0-9-]*)\b/);
25
13
  return Boolean(match && WORKFLOW_CONTROLLER_COMMANDS.has(match[4]) &&
26
14
  key(path.resolve(cwd, match[1] || match[2] || match[3])) === key(path.join(__dirname, 'workflow-disposition.js')));
27
15
  }
28
-
29
16
  function decide(payload) {
30
17
  if (!payload) return;
31
- const tool = payload.tool_name || payload.tool;
32
- if (!DIRECT.has(tool) && !SHELL.has(tool)) return;
33
- const context = loadWorkflow(payload);
34
- const { workflow, root, sessionDir } = context;
35
- if (!workflow || workflow.state === 'deferred') return;
36
- const cwd = cwdOf(payload, root);
37
- const command = commandOf(payload);
38
- const commandClass = SHELL.has(tool) ? classifyShell(command) : 'direct-mutation';
39
- if (SHELL.has(tool) && isController(command, cwd)) return;
40
- if (commandClass === 'read-only' || commandClass === 'worktree-setup') return;
41
- // Only the stage specification is allowed before run entry. Other runtime
42
- // state, including workflow-run.json, cannot use this bootstrap exception.
43
- const targets = DIRECT.has(tool) ? mutationPaths(payload) : [];
44
- const spec = path.join(sessionDir, 'workflow-stages.json');
45
- const specStat = fs.existsSync(spec) ? fs.lstatSync(spec) : null;
46
- if ((!specStat || (specStat.isFile() && !specStat.isSymbolicLink() && specStat.nlink === 1)) &&
47
- targets.length && targets.every(target => key(path.resolve(cwd, target)) === key(spec))) return;
48
- if (['blocked', 'failed'].includes(workflow.state)) throw new Error('workflow is BLOCKED; replan through the controller before mutation');
49
- if (isMajorWorkflow(workflow)) {
50
- const isolatedRoot = linkedWorktree(cwd, root);
51
- if (!isolatedRoot) throw new Error('Git worktree isolation is required before mutation. Enter a linked worktree; unavailable isolation means BLOCKED, never in-place fallback.');
52
- if (DIRECT.has(tool)) assertTargets(payload, cwd, isolatedRoot);
53
- else assertShellScope(command, cwd, isolatedRoot);
54
- }
55
- if (workflow.state === 'satisfied') throw new Error('workflow is already satisfied; submit a new task before further mutation');
56
- if (String(workflow.strategy || '').startsWith('fable-') && !matchingRun(context)) {
57
- 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.');
58
- }
59
- if (DIRECT.has(tool)) {
60
- // Bookkeeping outside the workspace (scratchpad, memory files, temp
61
- // notes) is not a workspace mutation: it consumes no Tier-2 iteration
62
- // and advances no mutation milestone (#165).
63
- if (!directMutationInWorkspace(payload, cwd, root)) return;
64
- if (workflow.strategy === 'iterative-single') {
65
- recordBudgetEvent(context, 'iteration', { evidence: `${tool}:direct-mutation` });
66
- }
67
- workflow.lastMutationAt = Date.now();
68
- saveWorkflow(context);
69
- return;
70
- }
71
-
72
- // Shell safety and shell mutation accounting are intentionally separate.
73
- // `untrusted` means "not proven read-only", not "a mutation occurred".
74
- // Capture an opaque pre-execution workspace fingerprint when possible. A
75
- // non-major workflow may run outside Git, where mutation observation is an
76
- // accounting aid rather than an isolation boundary; degrade conservatively
77
- // instead of blocking the user's command (#162).
78
- let fingerprint;
79
18
  try {
80
- fingerprint = workspaceFingerprint(cwd);
81
- } catch (error) {
82
- const fingerprintLimited = error && error.code === 'HARNESS_FINGERPRINT_LIMIT';
83
- if (isMajorWorkflow(workflow) && !fingerprintLimited) throw error;
84
- const evidence = fingerprintLimited ? 'shell:fingerprint-limit' : 'shell:unobservable-workspace';
85
- if (workflow.strategy === 'iterative-single') {
86
- recordBudgetEvent(context, 'iteration', { evidence });
19
+ const tool = payload.tool_name || payload.tool;
20
+ if (!DIRECT.has(tool) && !SHELL.has(tool)) return;
21
+ const context = loadWorkflow(payload);
22
+ const { workflow, root, sessionDir } = context;
23
+ if (!workflow || workflow.state === 'deferred') return;
24
+ const cwd = cwdOf(payload, root), command = commandOf(payload);
25
+ const commandClass = SHELL.has(tool) ? classifyShell(command) : 'direct-mutation';
26
+ if (SHELL.has(tool) && isController(command, cwd)) return;
27
+ if (commandClass === 'read-only' || commandClass === 'worktree-setup') return;
28
+ const targets = DIRECT.has(tool) ? mutationPaths(payload) : [];
29
+ const spec = path.join(sessionDir, 'workflow-stages.json');
30
+ const specStat = fs.existsSync(spec) ? fs.lstatSync(spec) : null;
31
+ if ((!specStat || (specStat.isFile() && !specStat.isSymbolicLink() && specStat.nlink === 1)) &&
32
+ targets.length && targets.every(target => key(path.resolve(cwd, target)) === key(spec))) return;
33
+ if (['blocked', 'failed'].includes(workflow.state)) warn('Workflow is marked ' + workflow.state.toUpperCase() + '; review the recorded reason before continuing.');
34
+ if (workflow.state === 'satisfied') warn('Workflow was already marked satisfied; confirm this mutation belongs to the current task.');
35
+ if (String(workflow.strategy || '').startsWith('fable-') && !matchingRun(context)) warn('Selected Fable workflow has no correlated run; consider creating/updating the stage map before broad changes.');
36
+ if (isMajorWorkflow(workflow)) {
37
+ const isolatedRoot = linkedWorktree(cwd, root);
38
+ if (!isolatedRoot) warn('This is a major workflow; a linked Git worktree is strongly recommended before mutation.');
39
+ else {
40
+ try { if (DIRECT.has(tool)) assertTargets(payload, cwd, isolatedRoot); else assertShellScope(command, cwd, isolatedRoot); }
41
+ catch (error) { warn(error.message); }
42
+ }
43
+ }
44
+ if (DIRECT.has(tool) && directMutationInWorkspace(payload, cwd, root)) {
45
+ workflow.lastMutationAt = Date.now();
46
+ saveWorkflow(context);
87
47
  }
88
- workflow.lastMutationAt = Date.now();
89
- saveWorkflow(context);
90
- return;
91
- }
92
- const probeKey = shellProbeKey(payload, cwd);
93
- const iterative = workflow.strategy === 'iterative-single';
94
- registerMutationProbe(context, probeKey, fingerprint, iterative, cwd, {
95
- countIteration: iterative,
96
- allowAtLimit: iterative && isVerificationShell(command),
97
- timeoutMs: shellTimeoutMs(payload),
98
- });
48
+ } catch (error) { warn(error && error.message ? error.message : String(error)); }
99
49
  }
100
-
101
50
  readHookInput(decide);
@@ -1,17 +1,5 @@
1
1
  #!/usr/bin/env node
2
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);
3
+ // Compatibility no-op: mutation reservations were removed by #190.
4
+ const { readHookInput } = require('./lib/workflow-runtime');
5
+ readHookInput(() => {});
@@ -1,52 +1,37 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
-
4
3
  const path = require('path');
5
4
  const { readJson } = require('./lib/fable-contracts');
6
5
  const { loadWorkflow, saveWorkflow, matchingRun, unresolvedStages, readHookInput } = require('./lib/workflow-runtime');
7
-
6
+ function warn(message) { console.error('[Workflow Reminder] ' + message); }
8
7
  function decide(payload) {
9
8
  if (!payload) return;
10
- const context = loadWorkflow(payload);
11
- const { workflow, sessionDir } = context;
12
- if (!workflow || ['deferred', 'satisfied'].includes(workflow.state)) return;
13
- if (workflow.state === 'blocked') {
14
- console.error('[Workflow Completion Gate] BLOCKED: report the unresolved work; completion has not been verified.');
15
- return;
16
- }
17
- let unresolved = [];
18
- if (String(workflow.strategy || '').startsWith('fable-')) {
19
- const match = matchingRun(context);
20
- unresolved = match ? unresolvedStages(match, workflow) : ['correlated-run-missing'];
21
- } else {
9
+ try {
10
+ const context = loadWorkflow(payload);
11
+ const { workflow, sessionDir } = context;
12
+ if (!workflow || workflow.state === 'deferred') return;
22
13
  const handoff = readJson(path.join(sessionDir, 'handoff-state.json'));
23
- const lastEdit = Math.max(workflow.lastMutationAt || 0, handoff?.lastEditAt || 0);
24
- // state-persist.js only ever records lastVerifyAt/lastVerifyExitCode from
25
- // its `!isFailed` branch, so reaching this timestamp already proves the
26
- // command succeeded. Do not additionally require lastVerifyExitCode===0:
27
- // hosts are not guaranteed to report a numeric exit code at all (some
28
- // PostToolUse payloads omit it entirely), in which case state-persist.js
29
- // correctly records success with lastVerifyExitCode: null, and requiring
30
- // a literal 0 here would make this gate permanently unresolvable on that
31
- // host after any edit.
32
- if (lastEdit && (handoff?.lastVerifyAt || 0) < lastEdit) unresolved = ['verification-after-edit-missing'];
33
- }
34
- if (unresolved.length) {
35
- workflow.unresolved = unresolved;
36
- if (payload.stop_hook_active) {
37
- // Do not loop forever or pretend the retry satisfied the contract.
38
- workflow.state = 'blocked';
39
- workflow.blockReason = 'unresolved-stop-retry';
14
+ let unresolved = [];
15
+ if (String(workflow.strategy || '').startsWith('fable-')) {
16
+ const match = matchingRun(context);
17
+ unresolved = match ? unresolvedStages(match, workflow, handoff?.lastEditAt || 0) : ['correlated-run-missing'];
18
+ } else {
19
+ const lastEdit = Math.max(workflow.lastMutationAt || 0, handoff?.lastEditAt || 0);
20
+ if (lastEdit && (handoff?.lastVerifyAt || 0) < lastEdit) unresolved = ['verification-after-edit-missing'];
21
+ }
22
+ const wasBlocked = workflow.state === 'blocked';
23
+ if (wasBlocked) warn('Workflow is marked BLOCKED; report or revisit the recorded blocker, but Harness will not trap the session.');
24
+ if (unresolved.length) {
25
+ workflow.unresolved = unresolved;
40
26
  saveWorkflow(context);
41
- console.error('[Workflow Completion Gate] BLOCKED: ' + unresolved.join(', ') + '. Report the blocker; this is not completion.');
27
+ warn('Unresolved workflow evidence: ' + unresolved.join(', ') + '. Verify/review before claiming completion.');
42
28
  return;
43
29
  }
30
+ if (wasBlocked) return;
31
+ workflow.state = 'satisfied';
32
+ workflow.resolvedAt = new Date().toISOString();
33
+ workflow.unresolved = [];
44
34
  saveWorkflow(context);
45
- throw new Error('Cannot complete: unresolved workflow obligations: ' + unresolved.join(', '));
46
- }
47
- workflow.state = 'satisfied';
48
- workflow.resolvedAt = new Date().toISOString();
49
- workflow.unresolved = [];
50
- saveWorkflow(context);
35
+ } catch (error) { warn(error && error.message ? error.message : String(error)); }
51
36
  }
52
37
  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.16.0",
4
+ "version": "0.16.1",
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",