harness-everything 0.8.0 → 0.8.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 (75) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.github/workflows/ci.yml +12 -2
  4. package/AGENTS.md +36 -35
  5. package/CHANGELOG.md +17 -0
  6. package/README.md +2 -2
  7. package/benchmarks/results/reviews/pr142-verification.json +95 -0
  8. package/ci/action-gate.test.js +1 -1
  9. package/ci/doc-capability-consistency.test.js +3 -0
  10. package/ci/invariant-routing.test.js +87 -74
  11. package/ci/mechanism-2af-worktree-isolation.test.js +152 -0
  12. package/ci/mechanism-2ag-router-worktree-contract.test.js +44 -0
  13. package/ci/mechanism-2ah-workflow-lifecycle.test.js +140 -0
  14. package/ci/openai-plugin-package.test.js +9 -6
  15. package/ci/workflow-execution-contract.test.js +60 -0
  16. package/docs/architecture.md +111 -114
  17. package/docs/mechanism-first-skill-mesh.md +92 -103
  18. package/docs/openai-plugin.md +1 -1
  19. package/docs/philosophy.md +40 -33
  20. package/docs/platform-capabilities.md +2 -2
  21. package/docs/reviews/pr-142-issue-131.md +89 -0
  22. package/docs/routing.md +61 -124
  23. package/docs/workflow-runtime.md +109 -0
  24. package/fable-mode/CONTRACT-FORMAT.md +3 -1
  25. package/fable-mode/SKILL.md +1 -1
  26. package/fable-mode/execution-guardrails/SKILL.md +1 -1
  27. package/fable-mode/fable-haiku/SKILL.md +1 -1
  28. package/fable-mode/fable-opus/SKILL.md +1 -1
  29. package/fable-mode/fable-sonnet/SKILL.md +1 -1
  30. package/fable-mode/scripts/workflow-plan-consumer.js +3 -1
  31. package/harness-everything/SKILL.md +11 -10
  32. package/harness-everything/references/triage-and-tiers.md +76 -107
  33. package/harness-everything/scripts/kernel-router-core.js +263 -0
  34. package/harness-everything/scripts/kernel-router.js +97 -225
  35. package/harness-everything/scripts/router-contract-core.js +612 -0
  36. package/harness-everything/scripts/router-contract.js +21 -581
  37. package/hooks/hooks.json +22 -1
  38. package/hooks/scripts/contract-test.js +24 -2
  39. package/hooks/scripts/lib/workflow-isolation.js +102 -0
  40. package/hooks/scripts/lib/workflow-runtime.js +102 -0
  41. package/hooks/scripts/state-persist.js +1 -0
  42. package/hooks/scripts/workflow-disposition.js +97 -0
  43. package/hooks/scripts/workflow-gate.js +53 -0
  44. package/hooks/scripts/workflow-stop-gate.js +44 -0
  45. package/opencode-plugin/plugin.json +1 -1
  46. package/package.json +2 -2
  47. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  48. package/plugins/harness-everything/hooks/hooks.json +20 -1
  49. package/plugins/harness-everything/hooks/scripts/contract-test.js +24 -2
  50. package/plugins/harness-everything/hooks/scripts/lib/workflow-isolation.js +102 -0
  51. package/plugins/harness-everything/hooks/scripts/lib/workflow-runtime.js +102 -0
  52. package/plugins/harness-everything/hooks/scripts/state-persist.js +1 -0
  53. package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +97 -0
  54. package/plugins/harness-everything/hooks/scripts/workflow-gate.js +53 -0
  55. package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +44 -0
  56. package/plugins/harness-everything/plugin.json +1 -1
  57. package/plugins/harness-everything/scripts/lib/advisory-text.js +51 -54
  58. package/plugins/harness-everything/skills/fable-mode/CONTRACT-FORMAT.md +3 -1
  59. package/plugins/harness-everything/skills/fable-mode/SKILL.md +1 -1
  60. package/plugins/harness-everything/skills/fable-mode/execution-guardrails/SKILL.md +1 -1
  61. package/plugins/harness-everything/skills/fable-mode/fable-haiku/SKILL.md +1 -1
  62. package/plugins/harness-everything/skills/fable-mode/fable-opus/SKILL.md +1 -1
  63. package/plugins/harness-everything/skills/fable-mode/fable-sonnet/SKILL.md +1 -1
  64. package/plugins/harness-everything/skills/fable-mode/scripts/workflow-plan-consumer.js +3 -1
  65. package/plugins/harness-everything/skills/harness-everything/SKILL.md +11 -10
  66. package/plugins/harness-everything/skills/harness-everything/references/triage-and-tiers.md +76 -107
  67. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router-core.js +263 -0
  68. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router.js +97 -225
  69. package/plugins/harness-everything/skills/harness-everything/scripts/router-contract-core.js +612 -0
  70. package/plugins/harness-everything/skills/harness-everything/scripts/router-contract.js +21 -581
  71. package/plugins/harness-everything/skills/using-git-worktrees/SKILL.md +14 -16
  72. package/plugins/harness-everything/skills/using-git-worktrees/references/workflow-details.md +44 -85
  73. package/scripts/lib/advisory-text.js +51 -54
  74. package/using-git-worktrees/SKILL.md +14 -16
  75. package/using-git-worktrees/references/workflow-details.md +44 -85
@@ -20,7 +20,9 @@ const {
20
20
  chooseCorrelatedContract,
21
21
  commandMatches,
22
22
  listRunContracts,
23
+ getWorkerId,
23
24
  } = require('./lib/fable-contracts');
25
+ const { loadWorkflow, matchingRun } = require('./lib/workflow-runtime');
24
26
 
25
27
  function resultState(payload) {
26
28
  const toolResponse = payload.tool_response || payload.tool_result || {};
@@ -41,6 +43,7 @@ function updateRunContract(entry, payload, command, sessionId) {
41
43
  const observedAt = new Date().toISOString();
42
44
  const contract = {
43
45
  ...entry.contract,
46
+ workerId: entry.contract.workerId || getWorkerId(payload),
44
47
  status: failed ? 'fail' : 'pass',
45
48
  lastObservedCommand: command,
46
49
  lastObservedExitCode: exitCode ?? null,
@@ -103,8 +106,11 @@ process.stdin.on('end', () => {
103
106
  const root = getWorkspaceRoot(payload);
104
107
  const stateRoot = getStateRoot(root, payload);
105
108
  const sessionId = getSessionId(payload);
109
+ const workflowContext = loadWorkflow(payload);
110
+ const activeRun = workflowContext.workflow?.workflowId ? matchingRun(workflowContext) : null;
106
111
  const runMatches = listRunContracts(stateRoot).filter(({ contract }) =>
107
- ['pending', 'planned', 'running'].includes(contract.status) && commandMatches(contract, command)
112
+ (contract.workflowId ? ['pending', 'planned', 'running', 'fail', 'pass'] : ['pending', 'planned', 'running']).includes(contract.status) && commandMatches(contract, command) &&
113
+ (!workflowContext.workflow?.workflowId || (activeRun && contract.runId === activeRun.run.runId && contract.sessionId === sessionId))
108
114
  );
109
115
 
110
116
  if (runMatches.length > 0) {
@@ -115,7 +121,23 @@ process.stdin.on('end', () => {
115
121
  process.exit(2);
116
122
  }
117
123
 
118
- const contract = updateRunContract(resolution.match, payload, command, sessionId);
124
+ const entry = resolution.match;
125
+ if (entry.contract.workflowId) {
126
+ const worker = getWorkerId(payload);
127
+ if (!worker || (entry.contract.workerId && worker !== entry.contract.workerId)) {
128
+ console.error('[Contract Test] Active workflow check lacks a matching observed worker identity.');
129
+ process.exit(2);
130
+ }
131
+ for (const id of entry.contract.dependsOn || []) {
132
+ const dependency = JSON.parse(fs.readFileSync(path.join(entry.runRoot, 'contracts', `${id}.json`), 'utf8'));
133
+ const escaped = workflowContext.workflow.escapes?.some(item => item.stageId === id && item.runId === entry.contract.runId);
134
+ if (dependency.status !== 'pass' && !escaped) {
135
+ console.error('[Contract Test] Dependency has not passed: ' + id);
136
+ process.exit(2);
137
+ }
138
+ }
139
+ }
140
+ const contract = updateRunContract(entry, payload, command, sessionId);
119
141
  console.log(`[Contract Test] ${contract.planId}/${contract.runId}/${contract.stageId}: ${contract.status.toUpperCase()} ${contract.checkCommand}`);
120
142
  if (contract.status === 'fail') {
121
143
  console.error(`Evidence: ${contract.evidence || '(no output captured)'}`);
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ function git(cwd, args) {
8
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, timeout: 5000 });
9
+ if (result.status !== 0) throw new Error('cannot verify Git worktree isolation');
10
+ return result.stdout.trim();
11
+ }
12
+
13
+ function canonical(value) {
14
+ const resolved = path.resolve(value);
15
+ // The JS resolver preserves Windows 8.3 spellings (RUNNER~1), while Git
16
+ // reports the long directory name. Native resolution gives one identity.
17
+ try { return fs.realpathSync.native(resolved); }
18
+ catch (error) {
19
+ if (error.code !== 'ENOENT') throw error;
20
+ const parent = path.dirname(resolved);
21
+ if (parent === resolved) return resolved;
22
+ return path.join(canonical(parent), path.basename(resolved));
23
+ }
24
+ }
25
+
26
+ function key(value) {
27
+ const result = canonical(value);
28
+ return process.platform === 'win32' ? result.toLowerCase() : result;
29
+ }
30
+
31
+ function within(root, target) {
32
+ const relative = path.relative(key(root), key(target));
33
+ return !path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`);
34
+ }
35
+
36
+ function linkedWorktree(cwd, boundRoot) {
37
+ const common = canonical(git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']));
38
+ const gitDir = canonical(git(cwd, ['rev-parse', '--absolute-git-dir']));
39
+ const boundCommon = canonical(git(boundRoot, ['rev-parse', '--path-format=absolute', '--git-common-dir']));
40
+ const top = canonical(git(cwd, ['rev-parse', '--show-toplevel']));
41
+ if (key(common) === key(gitDir) || key(common) !== key(boundCommon)) return null;
42
+ const worktrees = git(boundRoot, ['worktree', 'list', '--porcelain', '-z']).split('\0');
43
+ return worktrees.some(field => field.startsWith('worktree ') && key(field.slice(9)) === key(top)) ? top : null;
44
+ }
45
+
46
+ // A conservative read subset, not a general shell sandbox.
47
+ function classifyShell(command) {
48
+ const text = String(command || '').trim();
49
+ if (!text || /[;&|`\r\n<>$(){}\x00]/.test(text)) return 'mutation-or-unknown';
50
+ if (/^git\s+worktree\s+add\s+/i.test(text) && !/\s--(?:force|checkout|detach)(?:\s|=|$)/i.test(text)) return 'worktree-setup';
51
+ if (/\s(?:--(?:output|ext-diff|textconv|exec|pre|pre-glob|pager|open|batch|filters)|-[xoO])(?:\b|=)|\s-(?:exec|execdir|delete|fprint|fprintf)\b/i.test(text)) return 'mutation-or-unknown';
52
+ if (/^git\s+(?:status|rev-parse|diff|log|show|ls-files|check-ignore)(?:\s|$)/i.test(text) ||
53
+ /^git\s+branch\s+--show-current$/i.test(text) || /^git\s+worktree\s+list(?:\s|$)/i.test(text) ||
54
+ /^(?:pwd|ls|dir|cat|type|head|tail|wc|stat|rg|Get-Location|Get-ChildItem|Get-Content|Select-String|Test-Path)(?:\s|$)/i.test(text)) return 'read-only';
55
+ return 'mutation-or-unknown';
56
+ }
57
+
58
+ function inputOf(payload) { return payload?.tool_input ?? payload?.toolInput ?? payload?.input ?? {}; }
59
+ function cwdOf(payload, root) {
60
+ const input = inputOf(payload);
61
+ return input.workdir || input.cwd || input.working_directory || input.workingDirectory ||
62
+ payload.cwd || payload.working_directory || payload.workingDirectory || root;
63
+ }
64
+ function commandOf(payload) {
65
+ const input = inputOf(payload);
66
+ return String(input.command || input.script || input.cmd || '');
67
+ }
68
+ function mutationPaths(payload) {
69
+ const input = inputOf(payload);
70
+ if ((payload.tool_name || payload.tool) === 'apply_patch') {
71
+ const patch = typeof input === 'string' ? input : input.patch || input.input || '';
72
+ return [...String(patch).matchAll(/^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)\r?$/gm)].map(match => match[1].trim());
73
+ }
74
+ return [input.file_path || input.filePath || input.path].filter(value => typeof value === 'string' && value.trim());
75
+ }
76
+ function assertTargets(payload, cwd, isolatedRoot) {
77
+ const targets = mutationPaths(payload);
78
+ if (!targets.length) throw new Error('mutation target is unknown; cannot verify worktree isolation');
79
+ for (const target of targets) {
80
+ const resolved = path.resolve(cwd, target);
81
+ if (!within(isolatedRoot, resolved) || within(path.join(isolatedRoot, '.git'), resolved)) {
82
+ throw new Error('mutation target escapes Git worktree isolation or modifies Git metadata');
83
+ }
84
+ let parent = path.dirname(resolved);
85
+ while (!fs.existsSync(parent)) parent = path.dirname(parent);
86
+ if (key(git(parent, ['rev-parse', '--show-toplevel'])) !== key(isolatedRoot)) throw new Error('mutation target belongs to a different repository');
87
+ }
88
+ }
89
+ function assertShellScope(command, cwd, isolatedRoot) {
90
+ if (/(?:^|[;&|\r\n])\s*(?:cd|chdir|pushd|Set-Location|Push-Location)\b/i.test(command)) {
91
+ throw new Error('use the shell tool working-directory field so isolation can be verified');
92
+ }
93
+ const tokens = command.match(/"[^"]*"|'[^']*'|[^\s;&|<>]+/g) || [];
94
+ for (const token of tokens.slice(1)) {
95
+ const value = token.replace(/^["']|["']$/g, '').replace(/^[A-Za-z-]+=/, '');
96
+ if ((path.isAbsolute(value) || /^\.\.[\\/]/.test(value)) && !within(isolatedRoot, path.resolve(cwd, value))) {
97
+ throw new Error('explicit shell path escapes Git worktree isolation');
98
+ }
99
+ }
100
+ }
101
+
102
+ module.exports = { canonical, key, within, linkedWorktree, classifyShell, inputOf, cwdOf, commandOf, mutationPaths, assertTargets, assertShellScope };
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { getWorkspaceRoot, getSessionDir, getSessionId, getStateRoot } = require('./harness-state');
6
+ const { atomicWriteJson, readJson } = require('./fable-contracts');
7
+
8
+ const OPEN_STATES = new Set(['pending', 'active', 'running', 'failed', 'blocked', 'escaped']);
9
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10
+
11
+ function isMajorWorkflow(workflow) {
12
+ return Boolean(workflow && (workflow.mutationIsolation?.required || workflow.tier === 'tier3' || String(workflow.strategy || '').startsWith('fable-')));
13
+ }
14
+
15
+ function loadWorkflow(payload) {
16
+ const root = getWorkspaceRoot(payload);
17
+ const sessionId = getSessionId(payload);
18
+ const sessionDir = getSessionDir(root, sessionId, payload);
19
+ const file = path.join(sessionDir, 'workflow-run.json');
20
+ if (!fs.existsSync(file)) return { root, sessionId, sessionDir, file, workflow: null };
21
+ const workflow = readJson(file);
22
+ if (!workflow || !['deferred', 'satisfied', ...OPEN_STATES].includes(workflow.state)) {
23
+ throw new Error('recorded workflow is unreadable or invalid; repair its state before mutation/completion');
24
+ }
25
+ const strategies = ['direct-single', 'iterative-single', 'fable-staged', 'fable-parallel', 'fable-multi-agent-workspace'];
26
+ if (![1, 2].includes(workflow.schemaVersion) || workflow.sessionId !== sessionId ||
27
+ (workflow.state !== 'deferred' && !strategies.includes(workflow.strategy)) ||
28
+ (workflow.schemaVersion === 2 && (!workflow.workflowId || !workflow.workflowPlan ||
29
+ workflow.workflowPlan.strategy !== workflow.strategy || !Number.isInteger(workflow.revision) || workflow.revision < 0))) {
30
+ throw new Error('recorded workflow schema or session correlation is invalid');
31
+ }
32
+ return { root, sessionId, sessionDir, file, workflow };
33
+ }
34
+
35
+ function saveWorkflow(context) { atomicWriteJson(context.file, context.workflow); }
36
+
37
+ function matchingRun(context) {
38
+ const { workflow, root, sessionId } = context;
39
+ if (!workflow || !SAFE_ID.test(workflow.runId || '') || !workflow.workflowId || !sessionId) return null;
40
+ const runRoot = path.join(getStateRoot(root), 'fable-runs', workflow.runId);
41
+ const run = readJson(path.join(runRoot, 'run.json'));
42
+ if (!run || run.runId !== workflow.runId || run.workflowId !== workflow.workflowId ||
43
+ run.sessionId !== sessionId || run.strategy !== workflow.strategy ||
44
+ !Number.isFinite(Date.parse(run.createdAt)) || !Array.isArray(run.stageIds) || !run.stageIds.length ||
45
+ new Set(run.stageIds).size !== run.stageIds.length || run.stageIds.some(id => !SAFE_ID.test(id))) return null;
46
+ return { runRoot, run };
47
+ }
48
+
49
+ function unresolvedStages(match, workflow) {
50
+ const unresolved = [];
51
+ const stages = [];
52
+ for (const stageId of match.run.stageIds) {
53
+ const contract = readJson(path.join(match.runRoot, 'contracts', `${stageId}.json`));
54
+ if (!contract || contract.stageId !== stageId || contract.runId !== match.run.runId ||
55
+ contract.planId !== match.run.planId || contract.sessionId !== match.run.sessionId) {
56
+ unresolved.push(`${stageId}:missing-or-uncorrelated`);
57
+ continue;
58
+ }
59
+ stages.push(contract);
60
+ const escape = (workflow.escapes || []).find(item => item.stageId === stageId && item.runId === match.run.runId);
61
+ if (escape && escape.evidence && escape.uncoveredScope &&
62
+ ['workflow-uncovered-scope', 'host-capability-unavailable'].includes(escape.reasonCode)) continue;
63
+ const evidence = readJson(path.join(match.runRoot, 'evidence', `${stageId}.json`));
64
+ if (contract.status !== 'pass' || !contract.checkCommand || !evidence || evidence.status !== 'pass' ||
65
+ evidence.exitCode !== 0 || evidence.planId !== contract.planId || evidence.runId !== contract.runId ||
66
+ evidence.stageId !== stageId || evidence.sessionId !== contract.sessionId ||
67
+ evidence.checkCommand !== contract.checkCommand || !Number.isFinite(Date.parse(evidence.observedAt)) ||
68
+ evidence.workerId !== contract.workerId || !evidence.workerId || evidence.observedAt !== contract.verifiedAt) {
69
+ unresolved.push(`${stageId}:verification-unresolved`);
70
+ }
71
+ }
72
+ if (workflow.verification?.independent) {
73
+ const verifiers = stages.filter(stage => stage.agent === 'fable-verifier' && stage.writeSet?.length === 0);
74
+ const workers = stages.filter(stage => stage.agent !== 'fable-verifier').map(stage => stage.workerId).filter(Boolean);
75
+ const latestCheck = Math.max(0, ...stages.filter(stage => stage.agent !== 'fable-verifier').map(stage => Date.parse(stage.verifiedAt) || 0));
76
+ if (!verifiers.some(stage => stage.workerId && !workers.includes(stage.workerId) && stage.status === 'pass' &&
77
+ Date.parse(stage.verifiedAt) >= Math.max(workflow.lastMutationAt || 0, latestCheck) &&
78
+ match.run.stageIds.filter(id => id !== stage.stageId).every(id => stage.dependsOn?.includes(id)))) {
79
+ unresolved.push('independent-verifier-missing');
80
+ }
81
+ }
82
+ return unresolved;
83
+ }
84
+
85
+ function readHookInput(decide) {
86
+ let input = '';
87
+ let finished = false;
88
+ const finish = () => {
89
+ if (finished) return;
90
+ finished = true;
91
+ clearTimeout(timeout);
92
+ try { decide(input.trim() ? JSON.parse(input) : null); }
93
+ catch (error) { console.error(`[Workflow Gate] ${error.message}`); process.exitCode = 2; }
94
+ };
95
+ const timeout = setTimeout(finish, 500);
96
+ process.stdin.setEncoding('utf8');
97
+ process.stdin.on('data', chunk => { input += chunk; });
98
+ process.stdin.on('end', finish);
99
+ process.stdin.on('error', finish);
100
+ }
101
+
102
+ module.exports = { OPEN_STATES, SAFE_ID, isMajorWorkflow, loadWorkflow, saveWorkflow, matchingRun, unresolvedStages, readHookInput };
@@ -83,6 +83,7 @@ function processState(payload) {
83
83
  const command = (payload.tool_input && payload.tool_input.command) || '';
84
84
  if (VERIFY_COMMAND_RE.test(command)) {
85
85
  state.lastVerifyAt = Date.now();
86
+ state.lastVerifyExitCode = exitCode ?? null;
86
87
  }
87
88
  }
88
89
 
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { loadWorkflow, saveWorkflow, matchingRun, OPEN_STATES } = require('./lib/workflow-runtime');
7
+ const { getWorkspaceRoot, readCurrentSession } = require('./lib/harness-state');
8
+ const { atomicWriteJson, readJson } = require('./lib/fable-contracts');
9
+
10
+ const ALLOWED_ESCAPE_REASONS = new Set(['workflow-uncovered-scope', 'host-capability-unavailable']);
11
+
12
+ function parseArgs(argv) {
13
+ const args = { command: argv[0] };
14
+ const flags = new Map([['--reason-code', 'reasonCode'], ['--scope', 'scope'], ['--evidence', 'evidence'], ['--session-id', 'sessionId'], ['--stage-id', 'stageId']]);
15
+ for (let i = 1; i < argv.length; i++) {
16
+ const key = flags.get(argv[i]);
17
+ if (!key || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error('unknown or incomplete argument: ' + argv[i]);
18
+ args[key] = argv[++i];
19
+ }
20
+ return args;
21
+ }
22
+
23
+ function consumer() {
24
+ for (const relative of ['../../fable-mode/scripts/workflow-plan-consumer.js', '../../skills/fable-mode/scripts/workflow-plan-consumer.js']) {
25
+ const file = path.resolve(__dirname, relative);
26
+ if (fs.existsSync(file)) return require(file);
27
+ }
28
+ throw new Error('Fable consumer unavailable');
29
+ }
30
+
31
+ function main() {
32
+ const args = parseArgs(process.argv.slice(2));
33
+ const sessionId = args.sessionId || readCurrentSession(getWorkspaceRoot());
34
+ if (!sessionId) throw new Error('no active Harness session; pass --session-id explicitly');
35
+ const context = loadWorkflow({ session_id: sessionId });
36
+ const { workflow } = context;
37
+ if (!workflow || !OPEN_STATES.has(workflow.state)) throw new Error('no unresolved workflow contract exists for this session');
38
+ if (args.command === 'start') {
39
+ if (!workflow.workflowId || !workflow.workflowPlan) throw new Error('legacy workflow lacks a correlated plan; cannot start it implicitly');
40
+ if (workflow.state === 'running') throw new Error('workflow already running; record a blocker before replanning');
41
+ const max = workflow.workflowPlan.limits?.maxRevisionRounds ?? 2;
42
+ if (workflow.revision > max) throw new Error('replan budget exhausted; workflow remains blocked');
43
+ const plan = workflow.pendingPlan || workflow.workflowPlan;
44
+ if (!String(plan.strategy || '').startsWith('fable-')) {
45
+ workflow.revision++;
46
+ workflow.state = 'running';
47
+ delete workflow.blockReason;
48
+ saveWorkflow(context);
49
+ process.stdout.write(JSON.stringify({ state: workflow.state, workflowId: workflow.workflowId, revision: workflow.revision }) + '\n');
50
+ return;
51
+ }
52
+ const stages = readJson(path.join(context.sessionDir, 'workflow-stages.json'));
53
+ if (!Array.isArray(stages) || !stages.length) throw new Error('write a non-empty workflow-stages.json stage array at the session path first');
54
+ if (stages.some(stage => !stage.checkCommand || !stage.passCondition)) throw new Error('every required stage needs an objective checkCommand and passCondition');
55
+ if (plan.verification?.independent && !stages.some(stage => stage.agent === 'fable-verifier' && stage.writeSet?.length === 0 &&
56
+ stages.filter(other => other !== stage).every(other => stage.dependsOn?.includes(other.stageId)))) {
57
+ throw new Error('independent verification requires a read-only fable-verifier stage depending on all other stages');
58
+ }
59
+ const run = consumer().prepareRun({
60
+ routerContract: { workflowPlan: plan }, stages, workspaceRoot: context.root,
61
+ sessionId, workflowId: workflow.workflowId,
62
+ });
63
+ workflow.workflowPlan = plan;
64
+ workflow.strategy = plan.strategy;
65
+ workflow.tier = plan.tier;
66
+ workflow.verification = plan.verification;
67
+ workflow.mutationIsolation = plan.mutationIsolation || workflow.mutationIsolation;
68
+ workflow.runId = run.runId;
69
+ workflow.revision++;
70
+ workflow.state = 'running';
71
+ workflow.escapes = [];
72
+ delete workflow.pendingPlan;
73
+ delete workflow.blockReason;
74
+ } else if (args.command === 'escape') {
75
+ if (!ALLOWED_ESCAPE_REASONS.has(args.reasonCode)) throw new Error('generic simple/routine/already-clear reasons are intentionally rejected');
76
+ if (!args.scope?.trim()) throw new Error('--scope is required');
77
+ if (!args.evidence?.trim()) throw new Error('--evidence is required');
78
+ const match = matchingRun(context);
79
+ if (!match || !match.run.stageIds.includes(args.stageId)) throw new Error('--stage-id must name a stage in the correlated active run');
80
+ const contract = readJson(path.join(match.runRoot, 'contracts', args.stageId + '.json'));
81
+ if (contract?.agent === 'fable-verifier') throw new Error('independent verification cannot be escaped; record BLOCKED when unavailable');
82
+ const disposition = {
83
+ status: 'escaped', stageId: args.stageId, runId: match.run.runId,
84
+ reasonCode: args.reasonCode, uncoveredScope: args.scope.trim(), evidence: args.evidence.trim(), recordedAt: new Date().toISOString(),
85
+ };
86
+ workflow.escapes = [...(workflow.escapes || []).filter(item => item.stageId !== args.stageId), disposition];
87
+ atomicWriteJson(path.join(match.runRoot, 'escapes', args.stageId + '.json'), disposition);
88
+ // Scope-limited exception; never change the entire workflow to escaped.
89
+ } else if (args.command === 'block') {
90
+ if (!args.evidence?.trim()) throw new Error('--evidence is required');
91
+ workflow.state = 'blocked';
92
+ workflow.blockReason = args.evidence.trim();
93
+ } else throw new Error('Usage: workflow-disposition.js <start|escape|block> --session-id <id> [--stage-id <id> --reason-code <reason> --scope <scope> --evidence <evidence>]');
94
+ saveWorkflow(context);
95
+ process.stdout.write(JSON.stringify({ state: workflow.state, workflowId: workflow.workflowId, runId: workflow.runId, revision: workflow.revision, escapes: workflow.escapes }) + '\n');
96
+ }
97
+ try { main(); } catch (error) { console.error('[Workflow Disposition] ' + error.message); process.exitCode = 2; }
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const { loadWorkflow, saveWorkflow, isMajorWorkflow, matchingRun, readHookInput } = require('./lib/workflow-runtime');
7
+ const { key, classifyShell, cwdOf, commandOf, mutationPaths, linkedWorktree, assertTargets, assertShellScope } = require('./lib/workflow-isolation');
8
+
9
+ const DIRECT = new Set(['Edit', 'Write', 'apply_patch']);
10
+ const SHELL = new Set(['Bash', 'PowerShell', 'exec_command']);
11
+
12
+ function isController(command, cwd) {
13
+ // One trusted local executable, literal arguments, no shell evaluation.
14
+ if (/[;&|`\r\n<>$(){}]/.test(command)) return false;
15
+ const match = command.match(/^node\s+(?:"([^"]+)"|'([^']+)'|(\S+))\s+(start|escape|block)\b/);
16
+ return Boolean(match && key(path.resolve(cwd, match[1] || match[2] || match[3])) === key(path.join(__dirname, 'workflow-disposition.js')));
17
+ }
18
+
19
+ function decide(payload) {
20
+ if (!payload) return;
21
+ const tool = payload.tool_name || payload.tool;
22
+ if (!DIRECT.has(tool) && !SHELL.has(tool)) return;
23
+ const context = loadWorkflow(payload);
24
+ const { workflow, root, sessionDir } = context;
25
+ if (!workflow || workflow.state === 'deferred') return;
26
+ const cwd = cwdOf(payload, root);
27
+ const command = commandOf(payload);
28
+ const commandClass = SHELL.has(tool) ? classifyShell(command) : 'direct-mutation';
29
+ if (commandClass === 'read-only' || commandClass === 'worktree-setup') return;
30
+ if (SHELL.has(tool) && isController(command, cwd)) return;
31
+ // Only the stage specification is allowed before run entry. Other runtime
32
+ // state, including workflow-run.json, cannot use this bootstrap exception.
33
+ const targets = DIRECT.has(tool) ? mutationPaths(payload) : [];
34
+ const spec = path.join(sessionDir, 'workflow-stages.json');
35
+ const specStat = fs.existsSync(spec) ? fs.lstatSync(spec) : null;
36
+ if ((!specStat || (specStat.isFile() && !specStat.isSymbolicLink() && specStat.nlink === 1)) &&
37
+ targets.length && targets.every(target => key(path.resolve(cwd, target)) === key(spec))) return;
38
+ if (['blocked', 'failed'].includes(workflow.state)) throw new Error('workflow is BLOCKED; replan through the controller before mutation');
39
+ if (isMajorWorkflow(workflow)) {
40
+ const isolatedRoot = linkedWorktree(cwd, root);
41
+ if (!isolatedRoot) throw new Error('Git worktree isolation is required before mutation. Enter a linked worktree; unavailable isolation means BLOCKED, never in-place fallback.');
42
+ if (DIRECT.has(tool)) assertTargets(payload, cwd, isolatedRoot);
43
+ else assertShellScope(command, cwd, isolatedRoot);
44
+ }
45
+ if (workflow.state === 'satisfied') throw new Error('workflow is already satisfied; submit a new task before further mutation');
46
+ if (String(workflow.strategy || '').startsWith('fable-') && !matchingRun(context)) {
47
+ 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.');
48
+ }
49
+ workflow.lastMutationAt = Date.now();
50
+ saveWorkflow(context);
51
+ }
52
+
53
+ readHookInput(decide);
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('path');
5
+ const { readJson } = require('./lib/fable-contracts');
6
+ const { loadWorkflow, saveWorkflow, matchingRun, unresolvedStages, readHookInput } = require('./lib/workflow-runtime');
7
+
8
+ function decide(payload) {
9
+ 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 {
22
+ const handoff = readJson(path.join(sessionDir, 'handoff-state.json'));
23
+ const lastEdit = Math.max(workflow.lastMutationAt || 0, handoff?.lastEditAt || 0);
24
+ if (lastEdit && ((handoff?.lastVerifyAt || 0) < lastEdit || handoff?.lastVerifyExitCode !== 0)) unresolved = ['verification-after-edit-missing'];
25
+ }
26
+ if (unresolved.length) {
27
+ workflow.unresolved = unresolved;
28
+ if (payload.stop_hook_active) {
29
+ // Do not loop forever or pretend the retry satisfied the contract.
30
+ workflow.state = 'blocked';
31
+ workflow.blockReason = 'unresolved-stop-retry';
32
+ saveWorkflow(context);
33
+ console.error('[Workflow Completion Gate] BLOCKED: ' + unresolved.join(', ') + '. Report the blocker; this is not completion.');
34
+ return;
35
+ }
36
+ saveWorkflow(context);
37
+ throw new Error('Cannot complete: unresolved workflow obligations: ' + unresolved.join(', '));
38
+ }
39
+ workflow.state = 'satisfied';
40
+ workflow.resolvedAt = new Date().toISOString();
41
+ workflow.unresolved = [];
42
+ saveWorkflow(context);
43
+ }
44
+ readHookInput(decide);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-enforcement",
3
3
  "description": "Repository inventory for the opencode enforcement plugin and its standalone helper",
4
- "version": "0.8.0",
4
+ "version": "0.8.1",
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.8.0",
3
+ "version": "0.8.1",
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": {
@@ -26,7 +26,7 @@
26
26
  "test:collision": "node ci/description-collision.js",
27
27
  "test:routing:skills": "node ci/skill-routing-check.js",
28
28
  "test:routing": "node ci/routing-check.js",
29
- "test:routing:invariants": "node ci/invariant-routing.test.js && node ci/router-workflow-plan.test.js",
29
+ "test:routing:invariants": "node ci/invariant-routing.test.js && node ci/router-workflow-plan.test.js && node ci/workflow-execution-contract.test.js",
30
30
  "plugin:sync": "node scripts/sync-openai-plugin.js",
31
31
  "test:plugin:claude": "node ci/mechanism-2z-claude-plugin-manifest.test.js",
32
32
  "plugin:claude:stage": "node scripts/stage-claude-plugin.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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",
@@ -24,13 +24,25 @@
24
24
  "command": "node \"${PLUGIN_ROOT}/skills/harness-everything/scripts/kernel-router.js\"",
25
25
  "commandWindows": "node \"${PLUGIN_ROOT}\\skills\\harness-everything\\scripts\\kernel-router.js\"",
26
26
  "timeout": 30,
27
- "statusMessage": "Routing with Harness invariants",
27
+ "statusMessage": "Routing with Harness workflow contract",
28
28
  "additionalContextLimit": 2500
29
29
  }
30
30
  ]
31
31
  }
32
32
  ],
33
33
  "PreToolUse": [
34
+ {
35
+ "matcher": "Bash|apply_patch",
36
+ "hooks": [
37
+ {
38
+ "type": "command",
39
+ "command": "node \"${PLUGIN_ROOT}/hooks/scripts/workflow-gate.js\"",
40
+ "commandWindows": "node \"${PLUGIN_ROOT}\\hooks\\scripts\\workflow-gate.js\"",
41
+ "timeout": 15,
42
+ "statusMessage": "Checking Harness workflow and worktree-isolation contract"
43
+ }
44
+ ]
45
+ },
34
46
  {
35
47
  "matcher": "Bash|apply_patch",
36
48
  "hooks": [
@@ -183,6 +195,13 @@
183
195
  "timeout": 15,
184
196
  "statusMessage": "Closing unresolved Harness action approvals"
185
197
  },
198
+ {
199
+ "type": "command",
200
+ "command": "node \"${PLUGIN_ROOT}/hooks/scripts/workflow-stop-gate.js\"",
201
+ "commandWindows": "node \"${PLUGIN_ROOT}\\hooks\\scripts\\workflow-stop-gate.js\"",
202
+ "timeout": 15,
203
+ "statusMessage": "Checking Harness workflow completion gate"
204
+ },
186
205
  {
187
206
  "type": "command",
188
207
  "command": "node \"${PLUGIN_ROOT}/hooks/scripts/stop-gate.js\"",
@@ -20,7 +20,9 @@ const {
20
20
  chooseCorrelatedContract,
21
21
  commandMatches,
22
22
  listRunContracts,
23
+ getWorkerId,
23
24
  } = require('./lib/fable-contracts');
25
+ const { loadWorkflow, matchingRun } = require('./lib/workflow-runtime');
24
26
 
25
27
  function resultState(payload) {
26
28
  const toolResponse = payload.tool_response || payload.tool_result || {};
@@ -41,6 +43,7 @@ function updateRunContract(entry, payload, command, sessionId) {
41
43
  const observedAt = new Date().toISOString();
42
44
  const contract = {
43
45
  ...entry.contract,
46
+ workerId: entry.contract.workerId || getWorkerId(payload),
44
47
  status: failed ? 'fail' : 'pass',
45
48
  lastObservedCommand: command,
46
49
  lastObservedExitCode: exitCode ?? null,
@@ -103,8 +106,11 @@ process.stdin.on('end', () => {
103
106
  const root = getWorkspaceRoot(payload);
104
107
  const stateRoot = getStateRoot(root, payload);
105
108
  const sessionId = getSessionId(payload);
109
+ const workflowContext = loadWorkflow(payload);
110
+ const activeRun = workflowContext.workflow?.workflowId ? matchingRun(workflowContext) : null;
106
111
  const runMatches = listRunContracts(stateRoot).filter(({ contract }) =>
107
- ['pending', 'planned', 'running'].includes(contract.status) && commandMatches(contract, command)
112
+ (contract.workflowId ? ['pending', 'planned', 'running', 'fail', 'pass'] : ['pending', 'planned', 'running']).includes(contract.status) && commandMatches(contract, command) &&
113
+ (!workflowContext.workflow?.workflowId || (activeRun && contract.runId === activeRun.run.runId && contract.sessionId === sessionId))
108
114
  );
109
115
 
110
116
  if (runMatches.length > 0) {
@@ -115,7 +121,23 @@ process.stdin.on('end', () => {
115
121
  process.exit(2);
116
122
  }
117
123
 
118
- const contract = updateRunContract(resolution.match, payload, command, sessionId);
124
+ const entry = resolution.match;
125
+ if (entry.contract.workflowId) {
126
+ const worker = getWorkerId(payload);
127
+ if (!worker || (entry.contract.workerId && worker !== entry.contract.workerId)) {
128
+ console.error('[Contract Test] Active workflow check lacks a matching observed worker identity.');
129
+ process.exit(2);
130
+ }
131
+ for (const id of entry.contract.dependsOn || []) {
132
+ const dependency = JSON.parse(fs.readFileSync(path.join(entry.runRoot, 'contracts', `${id}.json`), 'utf8'));
133
+ const escaped = workflowContext.workflow.escapes?.some(item => item.stageId === id && item.runId === entry.contract.runId);
134
+ if (dependency.status !== 'pass' && !escaped) {
135
+ console.error('[Contract Test] Dependency has not passed: ' + id);
136
+ process.exit(2);
137
+ }
138
+ }
139
+ }
140
+ const contract = updateRunContract(entry, payload, command, sessionId);
119
141
  console.log(`[Contract Test] ${contract.planId}/${contract.runId}/${contract.stageId}: ${contract.status.toUpperCase()} ${contract.checkCommand}`);
120
142
  if (contract.status === 'fail') {
121
143
  console.error(`Evidence: ${contract.evidence || '(no output captured)'}`);