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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/ci.yml +12 -2
- package/AGENTS.md +36 -35
- package/CHANGELOG.md +51 -0
- package/README.md +2 -2
- package/benchmarks/results/reviews/pr142-verification.json +95 -0
- package/ci/action-gate.test.js +83 -3
- package/ci/doc-capability-consistency.test.js +3 -0
- package/ci/invariant-routing.test.js +87 -74
- package/ci/mechanism-2af-worktree-isolation.test.js +152 -0
- package/ci/mechanism-2ag-router-worktree-contract.test.js +44 -0
- package/ci/mechanism-2ah-workflow-lifecycle.test.js +140 -0
- package/ci/openai-plugin-package.test.js +9 -6
- package/ci/phase4-ensemble-memory.test.js +128 -10
- package/ci/router-workflow-plan.test.js +23 -0
- package/ci/workflow-execution-contract.test.js +133 -0
- package/ci/workflow-plan-consumers.test.js +18 -0
- package/docs/architecture.md +111 -114
- package/docs/mechanism-first-skill-mesh.md +92 -103
- package/docs/openai-plugin.md +1 -1
- package/docs/philosophy.md +40 -33
- package/docs/platform-capabilities.md +2 -2
- package/docs/reviews/pr-142-issue-131.md +89 -0
- package/docs/routing.md +61 -124
- package/docs/workflow-runtime.md +109 -0
- package/fable-mode/CONTRACT-FORMAT.md +3 -1
- package/fable-mode/SKILL.md +1 -1
- package/fable-mode/agents/fable-orchestrator.md +9 -7
- package/fable-mode/execution-guardrails/SKILL.md +1 -1
- package/fable-mode/fable-haiku/SKILL.md +1 -1
- package/fable-mode/fable-opus/SKILL.md +1 -1
- package/fable-mode/fable-sonnet/SKILL.md +1 -1
- package/fable-mode/scripts/workflow-plan-consumer.js +13 -8
- package/harness-everything/SKILL.md +11 -10
- package/harness-everything/references/triage-and-tiers.md +76 -107
- package/harness-everything/schemas/router-task-shape.schema.json +4 -2
- package/harness-everything/schemas/router-workflow-plan.schema.json +3 -2
- package/harness-everything/scripts/kernel-router-core.js +265 -0
- package/harness-everything/scripts/kernel-router.js +118 -223
- package/harness-everything/scripts/router-contract-core.js +633 -0
- package/harness-everything/scripts/router-contract.js +21 -581
- package/harness-everything/scripts/tier-router.js +19 -1
- package/hooks/hooks.json +25 -4
- package/hooks/scripts/action-gate.js +111 -2
- package/hooks/scripts/contract-test.js +27 -2
- package/hooks/scripts/lib/workflow-isolation.js +102 -0
- package/hooks/scripts/lib/workflow-runtime.js +254 -0
- package/hooks/scripts/state-persist.js +1 -0
- package/hooks/scripts/subagent-scope-guard.js +20 -3
- package/hooks/scripts/workflow-disposition.js +104 -0
- package/hooks/scripts/workflow-gate.js +56 -0
- package/hooks/scripts/workflow-stop-gate.js +44 -0
- package/multi-agent-workspace/SKILL.md +2 -2
- package/multi-agent-workspace/scripts/index_memory.js +107 -1
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +2 -2
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/hooks/hooks.json +44 -1
- package/plugins/harness-everything/hooks/scripts/action-gate.js +111 -2
- package/plugins/harness-everything/hooks/scripts/contract-test.js +27 -2
- package/plugins/harness-everything/hooks/scripts/lib/workflow-isolation.js +102 -0
- package/plugins/harness-everything/hooks/scripts/lib/workflow-runtime.js +254 -0
- package/plugins/harness-everything/hooks/scripts/state-persist.js +1 -0
- package/plugins/harness-everything/hooks/scripts/subagent-scope-guard.js +20 -3
- package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +104 -0
- package/plugins/harness-everything/hooks/scripts/workflow-gate.js +56 -0
- package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +44 -0
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/scripts/lib/advisory-text.js +51 -54
- package/plugins/harness-everything/skills/fable-mode/CONTRACT-FORMAT.md +3 -1
- package/plugins/harness-everything/skills/fable-mode/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/agents/fable-orchestrator.md +9 -7
- package/plugins/harness-everything/skills/fable-mode/execution-guardrails/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/fable-haiku/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/fable-opus/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/fable-sonnet/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/scripts/workflow-plan-consumer.js +13 -8
- package/plugins/harness-everything/skills/harness-everything/SKILL.md +11 -10
- package/plugins/harness-everything/skills/harness-everything/references/triage-and-tiers.md +76 -107
- package/plugins/harness-everything/skills/harness-everything/schemas/router-task-shape.schema.json +4 -2
- package/plugins/harness-everything/skills/harness-everything/schemas/router-workflow-plan.schema.json +3 -2
- package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router-core.js +265 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router.js +118 -223
- package/plugins/harness-everything/skills/harness-everything/scripts/router-contract-core.js +633 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/router-contract.js +21 -581
- package/plugins/harness-everything/skills/harness-everything/scripts/tier-router.js +19 -1
- package/plugins/harness-everything/skills/multi-agent-workspace/SKILL.md +2 -2
- package/plugins/harness-everything/skills/multi-agent-workspace/scripts/index_memory.js +107 -1
- package/plugins/harness-everything/skills/self-evolve/SKILL.md +6 -6
- package/plugins/harness-everything/skills/self-evolve/references/memory-resolution.md +22 -33
- package/plugins/harness-everything/skills/self-evolve/scripts/persist-memory.js +336 -57
- package/plugins/harness-everything/skills/self-evolve/scripts/self-regression.js +1 -0
- package/plugins/harness-everything/skills/using-git-worktrees/SKILL.md +14 -16
- package/plugins/harness-everything/skills/using-git-worktrees/references/workflow-details.md +44 -85
- package/scripts/lib/advisory-text.js +51 -54
- package/self-evolve/SKILL.md +6 -6
- package/self-evolve/references/memory-resolution.md +22 -33
- package/self-evolve/scripts/persist-memory.js +336 -57
- package/self-evolve/scripts/self-regression.js +1 -0
- package/using-git-worktrees/SKILL.md +14 -16
- 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
|
|
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
|
|
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 };
|