harness-everything 0.8.0 → 0.9.0

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 (102) 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 +51 -0
  6. package/README.md +2 -2
  7. package/benchmarks/results/reviews/pr142-verification.json +95 -0
  8. package/ci/action-gate.test.js +83 -3
  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/phase4-ensemble-memory.test.js +128 -10
  16. package/ci/router-workflow-plan.test.js +23 -0
  17. package/ci/workflow-execution-contract.test.js +133 -0
  18. package/ci/workflow-plan-consumers.test.js +18 -0
  19. package/docs/architecture.md +111 -114
  20. package/docs/mechanism-first-skill-mesh.md +92 -103
  21. package/docs/openai-plugin.md +1 -1
  22. package/docs/philosophy.md +40 -33
  23. package/docs/platform-capabilities.md +2 -2
  24. package/docs/reviews/pr-142-issue-131.md +89 -0
  25. package/docs/routing.md +61 -124
  26. package/docs/workflow-runtime.md +109 -0
  27. package/fable-mode/CONTRACT-FORMAT.md +3 -1
  28. package/fable-mode/SKILL.md +1 -1
  29. package/fable-mode/agents/fable-orchestrator.md +9 -7
  30. package/fable-mode/execution-guardrails/SKILL.md +1 -1
  31. package/fable-mode/fable-haiku/SKILL.md +1 -1
  32. package/fable-mode/fable-opus/SKILL.md +1 -1
  33. package/fable-mode/fable-sonnet/SKILL.md +1 -1
  34. package/fable-mode/scripts/workflow-plan-consumer.js +13 -8
  35. package/harness-everything/SKILL.md +11 -10
  36. package/harness-everything/references/triage-and-tiers.md +76 -107
  37. package/harness-everything/schemas/router-task-shape.schema.json +4 -2
  38. package/harness-everything/schemas/router-workflow-plan.schema.json +3 -2
  39. package/harness-everything/scripts/kernel-router-core.js +265 -0
  40. package/harness-everything/scripts/kernel-router.js +118 -223
  41. package/harness-everything/scripts/router-contract-core.js +633 -0
  42. package/harness-everything/scripts/router-contract.js +21 -581
  43. package/harness-everything/scripts/tier-router.js +19 -1
  44. package/hooks/hooks.json +25 -4
  45. package/hooks/scripts/action-gate.js +111 -2
  46. package/hooks/scripts/contract-test.js +27 -2
  47. package/hooks/scripts/lib/workflow-isolation.js +102 -0
  48. package/hooks/scripts/lib/workflow-runtime.js +254 -0
  49. package/hooks/scripts/state-persist.js +1 -0
  50. package/hooks/scripts/subagent-scope-guard.js +20 -3
  51. package/hooks/scripts/workflow-disposition.js +104 -0
  52. package/hooks/scripts/workflow-gate.js +56 -0
  53. package/hooks/scripts/workflow-stop-gate.js +44 -0
  54. package/multi-agent-workspace/SKILL.md +2 -2
  55. package/multi-agent-workspace/scripts/index_memory.js +107 -1
  56. package/opencode-plugin/plugin.json +1 -1
  57. package/package.json +2 -2
  58. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  59. package/plugins/harness-everything/hooks/hooks.json +44 -1
  60. package/plugins/harness-everything/hooks/scripts/action-gate.js +111 -2
  61. package/plugins/harness-everything/hooks/scripts/contract-test.js +27 -2
  62. package/plugins/harness-everything/hooks/scripts/lib/workflow-isolation.js +102 -0
  63. package/plugins/harness-everything/hooks/scripts/lib/workflow-runtime.js +254 -0
  64. package/plugins/harness-everything/hooks/scripts/state-persist.js +1 -0
  65. package/plugins/harness-everything/hooks/scripts/subagent-scope-guard.js +20 -3
  66. package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +104 -0
  67. package/plugins/harness-everything/hooks/scripts/workflow-gate.js +56 -0
  68. package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +44 -0
  69. package/plugins/harness-everything/plugin.json +1 -1
  70. package/plugins/harness-everything/scripts/lib/advisory-text.js +51 -54
  71. package/plugins/harness-everything/skills/fable-mode/CONTRACT-FORMAT.md +3 -1
  72. package/plugins/harness-everything/skills/fable-mode/SKILL.md +1 -1
  73. package/plugins/harness-everything/skills/fable-mode/agents/fable-orchestrator.md +9 -7
  74. package/plugins/harness-everything/skills/fable-mode/execution-guardrails/SKILL.md +1 -1
  75. package/plugins/harness-everything/skills/fable-mode/fable-haiku/SKILL.md +1 -1
  76. package/plugins/harness-everything/skills/fable-mode/fable-opus/SKILL.md +1 -1
  77. package/plugins/harness-everything/skills/fable-mode/fable-sonnet/SKILL.md +1 -1
  78. package/plugins/harness-everything/skills/fable-mode/scripts/workflow-plan-consumer.js +13 -8
  79. package/plugins/harness-everything/skills/harness-everything/SKILL.md +11 -10
  80. package/plugins/harness-everything/skills/harness-everything/references/triage-and-tiers.md +76 -107
  81. package/plugins/harness-everything/skills/harness-everything/schemas/router-task-shape.schema.json +4 -2
  82. package/plugins/harness-everything/skills/harness-everything/schemas/router-workflow-plan.schema.json +3 -2
  83. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router-core.js +265 -0
  84. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router.js +118 -223
  85. package/plugins/harness-everything/skills/harness-everything/scripts/router-contract-core.js +633 -0
  86. package/plugins/harness-everything/skills/harness-everything/scripts/router-contract.js +21 -581
  87. package/plugins/harness-everything/skills/harness-everything/scripts/tier-router.js +19 -1
  88. package/plugins/harness-everything/skills/multi-agent-workspace/SKILL.md +2 -2
  89. package/plugins/harness-everything/skills/multi-agent-workspace/scripts/index_memory.js +107 -1
  90. package/plugins/harness-everything/skills/self-evolve/SKILL.md +6 -6
  91. package/plugins/harness-everything/skills/self-evolve/references/memory-resolution.md +22 -33
  92. package/plugins/harness-everything/skills/self-evolve/scripts/persist-memory.js +336 -57
  93. package/plugins/harness-everything/skills/self-evolve/scripts/self-regression.js +1 -0
  94. package/plugins/harness-everything/skills/using-git-worktrees/SKILL.md +14 -16
  95. package/plugins/harness-everything/skills/using-git-worktrees/references/workflow-details.md +44 -85
  96. package/scripts/lib/advisory-text.js +51 -54
  97. package/self-evolve/SKILL.md +6 -6
  98. package/self-evolve/references/memory-resolution.md +22 -33
  99. package/self-evolve/scripts/persist-memory.js +336 -57
  100. package/self-evolve/scripts/self-regression.js +1 -0
  101. package/using-git-worktrees/SKILL.md +14 -16
  102. package/using-git-worktrees/references/workflow-details.md +44 -85
package/hooks/hooks.json CHANGED
@@ -21,13 +21,24 @@
21
21
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/harness-everything/scripts/kernel-router.js\""
22
22
  }
23
23
  ],
24
- "description": "Establish Harness routing invariants before host/domain skill selection",
24
+ "description": "Establish Harness routing invariants and an active workflow execution contract before host/domain skill selection",
25
25
  "id": "harness:pre:kernel-router"
26
26
  }
27
27
  ],
28
28
  "PreToolUse": [
29
29
  {
30
- "matcher": "Bash|PowerShell",
30
+ "matcher": "Bash|PowerShell|Edit|Write|apply_patch",
31
+ "hooks": [
32
+ {
33
+ "type": "command",
34
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/workflow-gate.js\""
35
+ }
36
+ ],
37
+ "description": "Enforce mandatory workflow entry and Tier 3 Git-worktree mutation isolation",
38
+ "id": "harness:pre:workflow-gate"
39
+ },
40
+ {
41
+ "matcher": "Bash|PowerShell|mcp__.*",
31
42
  "hooks": [
32
43
  {
33
44
  "type": "command",
@@ -104,6 +115,16 @@
104
115
  "description": "Close any unexecuted action-gate approvals as rejected for this turn",
105
116
  "id": "harness:stop:action-gate-audit"
106
117
  },
118
+ {
119
+ "hooks": [
120
+ {
121
+ "type": "command",
122
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/workflow-stop-gate.js\""
123
+ }
124
+ ],
125
+ "description": "Prevent completion while a selected Fable workflow has not reached verified stage resolution",
126
+ "id": "harness:stop:workflow-completion-gate"
127
+ },
107
128
  {
108
129
  "hooks": [
109
130
  {
@@ -117,7 +138,7 @@
117
138
  ],
118
139
  "PostToolUse": [
119
140
  {
120
- "matcher": "Bash|PowerShell",
141
+ "matcher": "Bash|PowerShell|mcp__.*",
121
142
  "hooks": [
122
143
  {
123
144
  "type": "command",
@@ -185,7 +206,7 @@
185
206
  ],
186
207
  "PostToolUseFailure": [
187
208
  {
188
- "matcher": "Bash|PowerShell",
209
+ "matcher": "Bash|PowerShell|mcp__.*",
189
210
  "hooks": [
190
211
  {
191
212
  "type": "command",
@@ -16,6 +16,8 @@ const VALID_SCOPES = new Set(['always', 'outside-scratch']);
16
16
  const POLICY_ENV = 'HARNESS_ACTION_GATE_POLICY';
17
17
  const DEFER_POLICY = 'defer-to-host';
18
18
  const ALWAYS_ASK_POLICY = 'always-ask';
19
+ const STRUCTURED_GATED_ACTIONS = new Set(['delete', 'publish', 'deploy', 'send', 'mutate']);
20
+ const STRUCTURED_SAFE_ACTIONS = new Set(['read']);
19
21
 
20
22
  // Fail-safe defaults are used only when the configured table is missing or
21
23
  // invalid. A valid-but-empty table stays empty so CI can catch policy erasure.
@@ -47,12 +49,113 @@ function toolInputOf(payload) {
47
49
  return (payload && (payload.tool_input || payload.toolInput || payload.input)) || {};
48
50
  }
49
51
 
52
+ function stableValue(value) {
53
+ if (Array.isArray(value)) return value.map(stableValue);
54
+ if (!value || typeof value !== 'object') return value;
55
+ return Object.keys(value).sort().reduce((acc, key) => {
56
+ acc[key] = stableValue(value[key]);
57
+ return acc;
58
+ }, {});
59
+ }
60
+
50
61
  function exactPayloadText(payload) {
51
62
  const input = toolInputOf(payload);
52
63
  const tool = toolNameOf(payload);
53
64
  if (tool === 'Bash' || tool === 'PowerShell') return String(input.command || '');
54
65
  if (tool === 'apply_patch') return String(input.patch || input.command || input.content || '');
55
- return JSON.stringify(input);
66
+ return JSON.stringify(stableValue(input));
67
+ }
68
+
69
+ function explicitActionDescriptor(payload) {
70
+ const input = toolInputOf(payload);
71
+ const candidates = [
72
+ payload && (payload.action_descriptor || payload.actionDescriptor),
73
+ input && (input.action_descriptor || input.actionDescriptor),
74
+ payload && payload.harness && (payload.harness.action_descriptor || payload.harness.actionDescriptor),
75
+ ];
76
+ return candidates.find(value => value && typeof value === 'object' && !Array.isArray(value)) || null;
77
+ }
78
+
79
+ function inferToolFamily(tool, explicit) {
80
+ if (explicit && ['shell', 'mcp', 'local-function'].includes(explicit.toolFamily)) return explicit.toolFamily;
81
+ if (tool === 'Bash' || tool === 'PowerShell') return 'shell';
82
+ if (/^mcp(?:__|_)/i.test(tool)) return 'mcp';
83
+ return 'local-function';
84
+ }
85
+
86
+ function inferStructuredAction(tool, explicit) {
87
+ const requested = explicit && String(explicit.action || '').toLowerCase();
88
+ if (['read', 'delete', 'publish', 'deploy', 'send', 'mutate', 'unknown'].includes(requested)) return requested;
89
+ const name = String(tool || '').toLowerCase();
90
+ if (/(?:^|[_-])(get|list|read|search|find|fetch|show|inspect)(?:$|[_-])/.test(name)) return 'read';
91
+ if (/(?:delete|remove|destroy|drop|purge)/.test(name)) return 'delete';
92
+ if (/(?:publish|release_create|create_release|release-publish)/.test(name)) return 'publish';
93
+ if (/(?:deploy|apply_production|promote)/.test(name)) return 'deploy';
94
+ if (/(?:send|email|message|notify|post_message)/.test(name)) return 'send';
95
+ if (/(?:create|update|write|edit|mutate|set_|add_|merge|close_)/.test(name)) return 'mutate';
96
+ return 'unknown';
97
+ }
98
+
99
+ function safeResource(input, explicit) {
100
+ const direct = explicit && explicit.resource;
101
+ if (typeof direct === 'string' && direct.trim()) return direct.trim().slice(0, 200);
102
+ for (const [key, value] of Object.entries(input || {})) {
103
+ if (/token|secret|password|credential|authorization|api[_-]?key/i.test(key)) continue;
104
+ if (!/resource|path|target|repo|repository|channel|issue|file|name|id$/i.test(key)) continue;
105
+ if (typeof value === 'string' || typeof value === 'number') return String(value).slice(0, 200);
106
+ }
107
+ return null;
108
+ }
109
+
110
+ function normalizeActionDescriptor(payload) {
111
+ const tool = toolNameOf(payload);
112
+ const input = toolInputOf(payload);
113
+ const explicit = explicitActionDescriptor(payload);
114
+ const toolFamily = inferToolFamily(tool, explicit);
115
+ const action = inferStructuredAction(tool, explicit);
116
+ const classification = STRUCTURED_GATED_ACTIONS.has(action)
117
+ ? 'gated'
118
+ : (STRUCTURED_SAFE_ACTIONS.has(action) ? 'safe' : 'unknown');
119
+ const reasonCodes = [];
120
+ if (explicit) reasonCodes.push('explicit-structured-action');
121
+ else if (action !== 'unknown') reasonCodes.push('tool-identity-inference');
122
+ else reasonCodes.push('structured-action-unknown');
123
+ return {
124
+ schemaVersion: 1,
125
+ toolFamily,
126
+ toolName: tool,
127
+ action,
128
+ resource: safeResource(input, explicit),
129
+ payloadHash: hashExact(exactPayloadText(payload)),
130
+ classification,
131
+ reasonCodes,
132
+ };
133
+ }
134
+
135
+ function structuredRule(payload) {
136
+ const descriptor = normalizeActionDescriptor(payload);
137
+ if (descriptor.toolFamily === 'shell') return { descriptor, rule: null };
138
+ if (descriptor.classification === 'safe') return { descriptor, rule: null };
139
+ if (descriptor.classification === 'gated') {
140
+ return {
141
+ descriptor,
142
+ rule: {
143
+ id: `structured-${descriptor.action}`,
144
+ reason: `Structured ${descriptor.action} action requires pre-action authorization.`,
145
+ },
146
+ };
147
+ }
148
+ const hint = routerActionGateHint(payload);
149
+ if (hint && hint.required) {
150
+ return {
151
+ descriptor,
152
+ rule: {
153
+ id: 'structured-unknown-required',
154
+ reason: 'The workflow requires an action gate, but this structured tool action is unknown.',
155
+ },
156
+ };
157
+ }
158
+ return { descriptor, rule: null };
56
159
  }
57
160
 
58
161
  function validateRule(rule) {
@@ -306,11 +409,13 @@ function recordDecision(payload, rule, table, host, disposition, options = {}, e
306
409
  const exact = exactPayloadText(payload);
307
410
  const commandHash = hashExact(exact);
308
411
  const now = new Date().toISOString();
412
+ const descriptor = normalizeActionDescriptor(payload);
309
413
  const record = {
310
414
  schemaVersion: 1,
311
415
  toolName: toolNameOf(payload),
312
416
  toolUseId: (payload && (payload.tool_use_id || payload.toolUseId)) || null,
313
417
  commandHash,
418
+ actionDescriptor: descriptor,
314
419
  matchedRule: rule.id,
315
420
  ruleReason: rule.reason,
316
421
  disposition,
@@ -332,7 +437,8 @@ function recordDecision(payload, rule, table, host, disposition, options = {}, e
332
437
  function evaluatePreToolUse(payload, options = {}) {
333
438
  const context = sessionContext(payload, options.sessionDir);
334
439
  const table = options.ruleTable || loadRuleTable(options.rulePath || RULES_PATH);
335
- const rule = classify(payload, table.rules, context.sessionDir);
440
+ const structured = structuredRule(payload);
441
+ const rule = structured.rule || classify(payload, table.rules, context.sessionDir);
336
442
  if (!rule) {
337
443
  return {
338
444
  kind: 'allow',
@@ -341,6 +447,7 @@ function evaluatePreToolUse(payload, options = {}) {
341
447
  stderr: table.degraded ? `[Harness actionGate] ${table.degradationReason}; built-in defaults active.` : null,
342
448
  rule: null,
343
449
  table,
450
+ descriptor: structured.descriptor,
344
451
  };
345
452
  }
346
453
 
@@ -535,4 +642,6 @@ module.exports = {
535
642
  routerActionGateHint,
536
643
  scratchRoots,
537
644
  validateRuleTable,
645
+ normalizeActionDescriptor,
646
+ structuredRule,
538
647
  };
@@ -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, recordBudgetEvent } = 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,9 +121,28 @@ 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') {
143
+ if (workflowContext.workflow?.workflowId) {
144
+ recordBudgetEvent(workflowContext, 'revision', { evidence: `${contract.stageId}:${contract.evidence || 'verification-failed'}` });
145
+ }
121
146
  console.error(`Evidence: ${contract.evidence || '(no output captured)'}`);
122
147
  console.error(`Do not build on this stage's output until it passes. Artifact: ${contract.outputPath || '(no path recorded)'}`);
123
148
  process.exit(2);
@@ -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,254 @@
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 budgetLimits(workflow) {
38
+ const limits = workflow?.workflowPlan?.limits || {};
39
+ return {
40
+ maxIterations: Number.isInteger(limits.maxIterations) && limits.maxIterations > 0 ? limits.maxIterations : null,
41
+ maxRevisionRounds: Number.isInteger(limits.maxRevisionRounds) && limits.maxRevisionRounds >= 0 ? limits.maxRevisionRounds : 0,
42
+ maxReplans: Number.isInteger(limits.maxReplans) && limits.maxReplans >= 0 ? limits.maxReplans : 0,
43
+ maxWorkers: Number.isInteger(limits.maxWorkers) && limits.maxWorkers > 0 ? limits.maxWorkers : 1,
44
+ };
45
+ }
46
+
47
+ function ensureWorkflowBudget(workflow) {
48
+ if (workflow.budget && workflow.budget.schemaVersion === 1) return workflow.budget;
49
+ const now = new Date().toISOString();
50
+ workflow.budget = {
51
+ schemaVersion: 1,
52
+ epoch: 0,
53
+ state: 'active',
54
+ limits: budgetLimits(workflow),
55
+ counters: { iterations: 0, revisionRounds: 0, replans: 0 },
56
+ activeWorkers: {},
57
+ reasonCode: null,
58
+ exhaustedAt: null,
59
+ resets: [],
60
+ events: [],
61
+ createdAt: now,
62
+ updatedAt: now,
63
+ };
64
+ return workflow.budget;
65
+ }
66
+
67
+ function syncBudgetToRun(context) {
68
+ const match = matchingRun(context);
69
+ if (!match || !context.workflow?.budget) return;
70
+ const updated = {
71
+ ...match.run,
72
+ budget: JSON.parse(JSON.stringify(context.workflow.budget)),
73
+ };
74
+ atomicWriteJson(path.join(match.runRoot, 'run.json'), updated);
75
+ }
76
+
77
+ function persistBudget(context) {
78
+ saveWorkflow(context);
79
+ syncBudgetToRun(context);
80
+ }
81
+
82
+ function budgetError(message) {
83
+ const error = new Error(message);
84
+ error.code = 'HARNESS_WORKFLOW_BUDGET';
85
+ return error;
86
+ }
87
+
88
+ function appendBudgetEvent(budget, event) {
89
+ budget.events = [...(budget.events || []), event].slice(-64);
90
+ budget.updatedAt = event.observedAt;
91
+ }
92
+
93
+ function exhaustBudget(context, reasonCode, evidence) {
94
+ const budget = ensureWorkflowBudget(context.workflow);
95
+ const now = new Date().toISOString();
96
+ budget.state = 'budget-exhausted';
97
+ budget.reasonCode = reasonCode;
98
+ budget.exhaustedAt = now;
99
+ appendBudgetEvent(budget, { type: 'budget-exhausted', reasonCode, evidence: evidence || null, observedAt: now });
100
+ context.workflow.state = 'blocked';
101
+ context.workflow.blockReason = reasonCode;
102
+ persistBudget(context);
103
+ throw budgetError(`budget-exhausted: ${reasonCode}`);
104
+ }
105
+
106
+ function recordBudgetEvent(context, type, options = {}) {
107
+ const { workflow } = context;
108
+ if (!workflow) throw budgetError('no workflow available for budget accounting');
109
+ const budget = ensureWorkflowBudget(workflow);
110
+ if (budget.state !== 'active') throw budgetError(`workflow budget is ${budget.state}: ${budget.reasonCode || 'unavailable'}`);
111
+ const now = new Date().toISOString();
112
+ const evidence = options.evidence || null;
113
+
114
+ const consume = (counter, limitName, reasonCode) => {
115
+ const limit = budget.limits[limitName];
116
+ if (limit === null) return { applicable: false, value: budget.counters[counter] };
117
+ if (budget.counters[counter] >= limit) return exhaustBudget(context, reasonCode, evidence);
118
+ budget.counters[counter]++;
119
+ appendBudgetEvent(budget, { type, counter, value: budget.counters[counter], limit, evidence, observedAt: now });
120
+ persistBudget(context);
121
+ return { applicable: true, value: budget.counters[counter], limit };
122
+ };
123
+
124
+ if (type === 'iteration') return consume('iterations', 'maxIterations', 'iteration-budget-exhausted');
125
+ if (type === 'revision') return consume('revisionRounds', 'maxRevisionRounds', 'revision-budget-exhausted');
126
+ if (type === 'replan') return consume('replans', 'maxReplans', 'replan-budget-exhausted');
127
+
128
+ if (type === 'worker-acquire') {
129
+ const workerId = String(options.workerId || '').trim();
130
+ if (!SAFE_ID.test(workerId)) throw budgetError('worker-acquire requires a stable safe worker id');
131
+ if (budget.activeWorkers[workerId]) return { active: Object.keys(budget.activeWorkers).length, idempotent: true };
132
+ const active = Object.keys(budget.activeWorkers).length;
133
+ if (active >= budget.limits.maxWorkers) return exhaustBudget(context, 'worker-budget-exhausted', evidence || workerId);
134
+ budget.activeWorkers[workerId] = { acquiredAt: now, evidence };
135
+ appendBudgetEvent(budget, { type, workerId, active: active + 1, limit: budget.limits.maxWorkers, evidence, observedAt: now });
136
+ persistBudget(context);
137
+ return { active: active + 1, limit: budget.limits.maxWorkers };
138
+ }
139
+
140
+ if (type === 'worker-release') {
141
+ const workerId = String(options.workerId || '').trim();
142
+ if (!SAFE_ID.test(workerId)) throw budgetError('worker-release requires a stable safe worker id');
143
+ const existed = Boolean(budget.activeWorkers[workerId]);
144
+ delete budget.activeWorkers[workerId];
145
+ appendBudgetEvent(budget, { type, workerId, existed, active: Object.keys(budget.activeWorkers).length, evidence, observedAt: now });
146
+ persistBudget(context);
147
+ return { active: Object.keys(budget.activeWorkers).length, existed };
148
+ }
149
+
150
+ throw budgetError(`unknown workflow budget event: ${type}`);
151
+ }
152
+
153
+ function resetWorkflowBudget(context, evidence) {
154
+ const { workflow } = context;
155
+ if (!workflow) throw budgetError('no workflow available for budget reset');
156
+ const previous = ensureWorkflowBudget(workflow);
157
+ if (previous.state !== 'budget-exhausted' || workflow.state !== 'blocked') {
158
+ throw budgetError('budget reset is allowed only after explicit budget exhaustion');
159
+ }
160
+ if (!String(evidence || '').trim()) throw budgetError('budget reset requires audit evidence');
161
+ const now = new Date().toISOString();
162
+ const resets = [...(previous.resets || []), {
163
+ epoch: previous.epoch,
164
+ reasonCode: previous.reasonCode,
165
+ counters: previous.counters,
166
+ evidence: String(evidence).trim(),
167
+ resetAt: now,
168
+ }].slice(-16);
169
+ workflow.budget = {
170
+ schemaVersion: 1,
171
+ epoch: previous.epoch + 1,
172
+ state: 'active',
173
+ limits: budgetLimits(workflow),
174
+ counters: { iterations: 0, revisionRounds: 0, replans: 0 },
175
+ activeWorkers: {},
176
+ reasonCode: null,
177
+ exhaustedAt: null,
178
+ resets,
179
+ events: [{ type: 'budget-reset', evidence: String(evidence).trim(), observedAt: now }],
180
+ createdAt: previous.createdAt || now,
181
+ updatedAt: now,
182
+ };
183
+ workflow.state = 'pending';
184
+ delete workflow.blockReason;
185
+ persistBudget(context);
186
+ return workflow.budget;
187
+ }
188
+
189
+ function matchingRun(context) {
190
+ const { workflow, root, sessionId } = context;
191
+ if (!workflow || !SAFE_ID.test(workflow.runId || '') || !workflow.workflowId || !sessionId) return null;
192
+ const runRoot = path.join(getStateRoot(root), 'fable-runs', workflow.runId);
193
+ const run = readJson(path.join(runRoot, 'run.json'));
194
+ if (!run || run.runId !== workflow.runId || run.workflowId !== workflow.workflowId ||
195
+ run.sessionId !== sessionId || run.strategy !== workflow.strategy ||
196
+ !Number.isFinite(Date.parse(run.createdAt)) || !Array.isArray(run.stageIds) || !run.stageIds.length ||
197
+ new Set(run.stageIds).size !== run.stageIds.length || run.stageIds.some(id => !SAFE_ID.test(id))) return null;
198
+ return { runRoot, run };
199
+ }
200
+
201
+ function unresolvedStages(match, workflow) {
202
+ const unresolved = [];
203
+ const stages = [];
204
+ for (const stageId of match.run.stageIds) {
205
+ const contract = readJson(path.join(match.runRoot, 'contracts', `${stageId}.json`));
206
+ if (!contract || contract.stageId !== stageId || contract.runId !== match.run.runId ||
207
+ contract.planId !== match.run.planId || contract.sessionId !== match.run.sessionId) {
208
+ unresolved.push(`${stageId}:missing-or-uncorrelated`);
209
+ continue;
210
+ }
211
+ stages.push(contract);
212
+ const escape = (workflow.escapes || []).find(item => item.stageId === stageId && item.runId === match.run.runId);
213
+ if (escape && escape.evidence && escape.uncoveredScope &&
214
+ ['workflow-uncovered-scope', 'host-capability-unavailable'].includes(escape.reasonCode)) continue;
215
+ const evidence = readJson(path.join(match.runRoot, 'evidence', `${stageId}.json`));
216
+ if (contract.status !== 'pass' || !contract.checkCommand || !evidence || evidence.status !== 'pass' ||
217
+ evidence.exitCode !== 0 || evidence.planId !== contract.planId || evidence.runId !== contract.runId ||
218
+ evidence.stageId !== stageId || evidence.sessionId !== contract.sessionId ||
219
+ evidence.checkCommand !== contract.checkCommand || !Number.isFinite(Date.parse(evidence.observedAt)) ||
220
+ evidence.workerId !== contract.workerId || !evidence.workerId || evidence.observedAt !== contract.verifiedAt) {
221
+ unresolved.push(`${stageId}:verification-unresolved`);
222
+ }
223
+ }
224
+ if (workflow.verification?.independent) {
225
+ const verifiers = stages.filter(stage => stage.agent === 'fable-verifier' && stage.writeSet?.length === 0);
226
+ const workers = stages.filter(stage => stage.agent !== 'fable-verifier').map(stage => stage.workerId).filter(Boolean);
227
+ const latestCheck = Math.max(0, ...stages.filter(stage => stage.agent !== 'fable-verifier').map(stage => Date.parse(stage.verifiedAt) || 0));
228
+ if (!verifiers.some(stage => stage.workerId && !workers.includes(stage.workerId) && stage.status === 'pass' &&
229
+ Date.parse(stage.verifiedAt) >= Math.max(workflow.lastMutationAt || 0, latestCheck) &&
230
+ match.run.stageIds.filter(id => id !== stage.stageId).every(id => stage.dependsOn?.includes(id)))) {
231
+ unresolved.push('independent-verifier-missing');
232
+ }
233
+ }
234
+ return unresolved;
235
+ }
236
+
237
+ function readHookInput(decide) {
238
+ let input = '';
239
+ let finished = false;
240
+ const finish = () => {
241
+ if (finished) return;
242
+ finished = true;
243
+ clearTimeout(timeout);
244
+ try { decide(input.trim() ? JSON.parse(input) : null); }
245
+ catch (error) { console.error(`[Workflow Gate] ${error.message}`); process.exitCode = 2; }
246
+ };
247
+ const timeout = setTimeout(finish, 500);
248
+ process.stdin.setEncoding('utf8');
249
+ process.stdin.on('data', chunk => { input += chunk; });
250
+ process.stdin.on('end', finish);
251
+ process.stdin.on('error', finish);
252
+ }
253
+
254
+ module.exports = { OPEN_STATES, SAFE_ID, isMajorWorkflow, loadWorkflow, saveWorkflow, budgetLimits, ensureWorkflowBudget, recordBudgetEvent, resetWorkflowBudget, syncBudgetToRun, 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