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
|
@@ -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 };
|
|
@@ -30,6 +30,17 @@ const {
|
|
|
30
30
|
getWorkerId,
|
|
31
31
|
stagesForChangedPath,
|
|
32
32
|
} = require('./lib/fable-contracts');
|
|
33
|
+
const { loadWorkflow, recordBudgetEvent } = require('./lib/workflow-runtime');
|
|
34
|
+
|
|
35
|
+
function accountWorker(payload, event) {
|
|
36
|
+
const context = loadWorkflow(payload);
|
|
37
|
+
const workflow = context.workflow;
|
|
38
|
+
if (!workflow || workflow.state !== 'running' || !String(workflow.strategy || '').startsWith('fable-')) return;
|
|
39
|
+
const workerId = getWorkerId(payload);
|
|
40
|
+
if (!workerId) return;
|
|
41
|
+
const type = event === 'PreToolUse' || event === 'SubagentStart' ? 'worker-acquire' : 'worker-release';
|
|
42
|
+
recordBudgetEvent(context, type, { workerId, evidence: event });
|
|
43
|
+
}
|
|
33
44
|
|
|
34
45
|
function gitStatus(root) {
|
|
35
46
|
try {
|
|
@@ -112,6 +123,7 @@ process.stdin.on('end', () => {
|
|
|
112
123
|
const stateFile = path.join(sessionDir, 'subagent-scope-state.json');
|
|
113
124
|
|
|
114
125
|
if (event === 'PreToolUse' || event === 'SubagentStart') {
|
|
126
|
+
accountWorker(payload, event);
|
|
115
127
|
if (!fs.existsSync(stateFile)) {
|
|
116
128
|
const status = gitStatus(root);
|
|
117
129
|
if (status !== null) {
|
|
@@ -126,6 +138,7 @@ process.stdin.on('end', () => {
|
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
if (event === 'PostToolUse' || event === 'SubagentStop') {
|
|
141
|
+
accountWorker(payload, event);
|
|
129
142
|
if (!fs.existsSync(stateFile)) process.exit(0);
|
|
130
143
|
|
|
131
144
|
let state;
|
|
@@ -182,9 +195,13 @@ process.stdin.on('end', () => {
|
|
|
182
195
|
}
|
|
183
196
|
|
|
184
197
|
process.exit(0);
|
|
185
|
-
} catch (
|
|
186
|
-
|
|
187
|
-
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error && error.code === 'HARNESS_WORKFLOW_BUDGET') {
|
|
200
|
+
console.error(`[Subagent Scope Guard] ${error.message}`);
|
|
201
|
+
process.exit(2);
|
|
202
|
+
}
|
|
203
|
+
// This is otherwise a post-execution visibility gate. Runtime errors fail open;
|
|
204
|
+
// actionGate has a stricter fail-ask policy for side effects.
|
|
188
205
|
process.exit(0);
|
|
189
206
|
}
|
|
190
207
|
});
|