@pedyc/harness-core 1.0.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/LICENSE +21 -0
- package/README.md +48 -0
- package/package.json +47 -0
- package/src/agent.mjs +27 -0
- package/src/command.mjs +28 -0
- package/src/index.mjs +11 -0
- package/src/intake.mjs +23 -0
- package/src/orchestrator.mjs +119 -0
- package/src/package-manager.mjs +17 -0
- package/src/policy.mjs +19 -0
- package/src/provider.mjs +32 -0
- package/src/schema.mjs +24 -0
- package/src/snapshots.mjs +20 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pedyc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @pedyc/harness-core
|
|
2
|
+
|
|
3
|
+
Technology-stack agnostic runtime primitives for Pedyc Harness. The package has no
|
|
4
|
+
dependency on Vue or on any project layout, and is used by `pedyc-harness`.
|
|
5
|
+
|
|
6
|
+
## API
|
|
7
|
+
|
|
8
|
+
```js
|
|
9
|
+
import {
|
|
10
|
+
detectPackageManager,
|
|
11
|
+
packageScriptCommand,
|
|
12
|
+
normalizeTask,
|
|
13
|
+
readTaskFile,
|
|
14
|
+
runCommand,
|
|
15
|
+
changedFiles,
|
|
16
|
+
snapshotFiles,
|
|
17
|
+
loadSchemas,
|
|
18
|
+
createValidators,
|
|
19
|
+
validationDetails,
|
|
20
|
+
parseAgentResponse,
|
|
21
|
+
validateStageResponse,
|
|
22
|
+
validatePolicy,
|
|
23
|
+
findOutOfScopeChanges,
|
|
24
|
+
isCommandAllowed,
|
|
25
|
+
createProviderRunner,
|
|
26
|
+
runOrchestrator,
|
|
27
|
+
} from '@pedyc/harness-core'
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Individual modules are also exported for narrower imports:
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
import { detectPackageManager } from '@pedyc/harness-core/package-manager'
|
|
34
|
+
import { runOrchestrator } from '@pedyc/harness-core/orchestrator'
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- `package-manager` / `command` — lockfile detection and command execution.
|
|
38
|
+
- `intake` / `schema` / `agent` — task normalization, Ajv validation and Agent
|
|
39
|
+
response parsing.
|
|
40
|
+
- `policy` / `provider` / `orchestrator` — path policy, Provider routing and the
|
|
41
|
+
four-phase execution loop.
|
|
42
|
+
|
|
43
|
+
`runOrchestrator({ dryRun: true })` skips every Agent Provider and verification gate
|
|
44
|
+
and returns a structured passing result.
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pedyc/harness-core",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Runtime primitives for Pedyc Harness",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.mjs",
|
|
9
|
+
"./package-manager": "./src/package-manager.mjs",
|
|
10
|
+
"./intake": "./src/intake.mjs",
|
|
11
|
+
"./command": "./src/command.mjs",
|
|
12
|
+
"./snapshots": "./src/snapshots.mjs",
|
|
13
|
+
"./schema": "./src/schema.mjs",
|
|
14
|
+
"./agent": "./src/agent.mjs",
|
|
15
|
+
"./policy": "./src/policy.mjs",
|
|
16
|
+
"./provider": "./src/provider.mjs",
|
|
17
|
+
"./orchestrator": "./src/orchestrator.mjs"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/pedyc/pedyc-harness.git",
|
|
22
|
+
"directory": "packages/core"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/pedyc/pedyc-harness#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/pedyc/pedyc-harness/issues"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"agent",
|
|
30
|
+
"harness",
|
|
31
|
+
"orchestration",
|
|
32
|
+
"runtime"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"ajv": "^8.20.0"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"src",
|
|
45
|
+
"README.md"
|
|
46
|
+
]
|
|
47
|
+
}
|
package/src/agent.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const parseAgentResponse = (name, stdout, validate, ajv) => {
|
|
2
|
+
const trimmed = stdout.trim()
|
|
3
|
+
if (!trimmed) return { ok: true, details: `${name} completed without a response payload.`, payload: {} }
|
|
4
|
+
try {
|
|
5
|
+
const payload = JSON.parse(trimmed)
|
|
6
|
+
if (!validate(payload)) {
|
|
7
|
+
return { ok: false, details: `${name} returned an invalid response: ${ajv.errorsText(validate.errors)}`, payload: {} }
|
|
8
|
+
}
|
|
9
|
+
return { ok: true, details: payload.details ?? `${name} returned a structured response.`, payload }
|
|
10
|
+
} catch {
|
|
11
|
+
return { ok: false, details: `${name} must return one JSON object on stdout.`, payload: {} }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const validateStageResponse = (name, payload) => {
|
|
16
|
+
if (name === 'planner' && (!Array.isArray(payload.implementationPlan) || payload.implementationPlan.length === 0)) {
|
|
17
|
+
return 'planner must return a non-empty implementationPlan.'
|
|
18
|
+
}
|
|
19
|
+
if (name === 'tester') {
|
|
20
|
+
if (typeof payload.approved !== 'boolean') return 'tester must return a boolean approved field.'
|
|
21
|
+
if (!Array.isArray(payload.evidence) || payload.evidence.length === 0) return 'tester must return non-empty evidence.'
|
|
22
|
+
}
|
|
23
|
+
if (name === 'reviewer' && typeof payload.approved !== 'boolean') {
|
|
24
|
+
return 'reviewer must return a boolean approved field.'
|
|
25
|
+
}
|
|
26
|
+
return null
|
|
27
|
+
}
|
package/src/command.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
const normalizeCommand = (command) => {
|
|
4
|
+
if (
|
|
5
|
+
process.platform === 'win32'
|
|
6
|
+
&& ['npm', 'npx', 'pnpm', 'yarn'].includes(command)
|
|
7
|
+
&& !command.endsWith('.cmd')
|
|
8
|
+
) {
|
|
9
|
+
return `${command}.cmd`
|
|
10
|
+
}
|
|
11
|
+
return command
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const runCommand = (root, command, args = [], stdin = null) => new Promise((resolve) => {
|
|
15
|
+
const child = spawn(normalizeCommand(command), args, {
|
|
16
|
+
cwd: root,
|
|
17
|
+
shell: process.platform === 'win32',
|
|
18
|
+
windowsHide: true,
|
|
19
|
+
})
|
|
20
|
+
let stdout = ''
|
|
21
|
+
let stderr = ''
|
|
22
|
+
child.stdout.on('data', (chunk) => { stdout += chunk })
|
|
23
|
+
child.stderr.on('data', (chunk) => { stderr += chunk })
|
|
24
|
+
child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }))
|
|
25
|
+
child.on('error', (error) => resolve({ code: 1, stdout, stderr: error.message }))
|
|
26
|
+
if (stdin !== null) child.stdin.write(`${JSON.stringify(stdin)}\n`)
|
|
27
|
+
child.stdin.end()
|
|
28
|
+
})
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { detectPackageManager, packageScriptCommand } from './package-manager.mjs'
|
|
2
|
+
export { normalizeTask, readTaskFile } from './intake.mjs'
|
|
3
|
+
export { runCommand } from './command.mjs'
|
|
4
|
+
export { changedFiles, snapshotFiles } from './snapshots.mjs'
|
|
5
|
+
export { loadSchemas, createValidators, validationDetails } from './schema.mjs'
|
|
6
|
+
export { parseAgentResponse, validateStageResponse } from './agent.mjs'
|
|
7
|
+
export { validatePolicy, findOutOfScopeChanges, isCommandAllowed } from './policy.mjs'
|
|
8
|
+
export { createProviderRunner } from './provider.mjs'
|
|
9
|
+
export { runOrchestrator } from './orchestrator.mjs'
|
|
10
|
+
|
|
11
|
+
export const harnessCoreVersion = '1.0.0'
|
package/src/intake.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
const defaultChecks = ['pnpm run harness:verify', 'pnpm run type-check', 'pnpm run test:unit', 'pnpm run build']
|
|
4
|
+
|
|
5
|
+
export const normalizeTask = (task) => {
|
|
6
|
+
const normalized = {
|
|
7
|
+
feature: task.task?.trim() || task.feature?.trim() || '',
|
|
8
|
+
objective: task.goal?.trim() || task.objective?.trim() || '',
|
|
9
|
+
constraints: task.specialConstraints ?? task.constraints ?? [],
|
|
10
|
+
acceptanceCriteria: task.acceptance ?? task.acceptanceCriteria ?? [],
|
|
11
|
+
testHints: task.testHints ?? defaultChecks,
|
|
12
|
+
maxIterations: task.maxIterations ?? 3,
|
|
13
|
+
}
|
|
14
|
+
const questions = []
|
|
15
|
+
if (!normalized.feature) questions.push('要实现的任务或功能是什么?')
|
|
16
|
+
if (!normalized.objective) questions.push('任务的目标是什么?')
|
|
17
|
+
if (!normalized.acceptanceCriteria.length) questions.push('完成任务的验收标准是什么?')
|
|
18
|
+
return questions.length > 0
|
|
19
|
+
? { status: 'needs_input', questions, normalizedTask: normalized }
|
|
20
|
+
: { status: 'ready', normalizedTask: normalized, questions: [] }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const readTaskFile = (path) => normalizeTask(JSON.parse(readFileSync(path, 'utf8')))
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { findOutOfScopeChanges } from './policy.mjs'
|
|
2
|
+
|
|
3
|
+
export const runOrchestrator = async ({
|
|
4
|
+
input,
|
|
5
|
+
policy,
|
|
6
|
+
dryRun,
|
|
7
|
+
snapshot,
|
|
8
|
+
changedFiles,
|
|
9
|
+
runAgent,
|
|
10
|
+
runVerification,
|
|
11
|
+
writeVerification = () => {},
|
|
12
|
+
}) => {
|
|
13
|
+
const maxIterations = Math.min(
|
|
14
|
+
Math.max(Number(input.maxIterations ?? policy.maxIterations ?? 3), 1),
|
|
15
|
+
policy.maxIterations ?? 3,
|
|
16
|
+
)
|
|
17
|
+
const phases = []
|
|
18
|
+
const issues = []
|
|
19
|
+
const fileChanges = []
|
|
20
|
+
const implementationPlan = [
|
|
21
|
+
`Analyze the requested feature: ${input.feature.trim()}.`,
|
|
22
|
+
`Implement the objective while satisfying ${input.acceptanceCriteria.length} acceptance criteria.`,
|
|
23
|
+
'Run configured verification gates and review the result before reporting completion.',
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
if (dryRun) {
|
|
27
|
+
// A dry run is a safe preview: no Agent Provider is invoked, no verification
|
|
28
|
+
// gate is executed, and no product file can change. Report the same four
|
|
29
|
+
// phases so callers can consume the result with the regular output contract.
|
|
30
|
+
for (const [name, details] of [
|
|
31
|
+
['planner', 'Dry run: planner execution skipped; no agent provider was invoked.'],
|
|
32
|
+
['coder', 'Dry run: coder execution skipped; no product files were changed.'],
|
|
33
|
+
['tester', 'Dry run: tester execution skipped; no verification gates were executed.'],
|
|
34
|
+
['reviewer', 'Dry run: reviewer execution skipped; no product files were changed.'],
|
|
35
|
+
]) {
|
|
36
|
+
phases.push({ name, iteration: 1, status: 'passed', details })
|
|
37
|
+
}
|
|
38
|
+
return { completed: true, implementationPlan, fileChanges, verification: [], issues, phases, iterations: 1 }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const recordPhase = (name, status, details, iteration) => {
|
|
42
|
+
const phase = { name, status, details }
|
|
43
|
+
if (iteration) phase.iteration = iteration
|
|
44
|
+
phases.push(phase)
|
|
45
|
+
return phase
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
recordPhase('planner', 'running', 'Validating task input and preparing an implementation plan.')
|
|
49
|
+
const plan = await runAgent('planner', { phase: 'planner', input, implementationPlan })
|
|
50
|
+
phases[phases.length - 1] = { name: 'planner', status: plan.ok ? 'passed' : 'failed', details: plan.details }
|
|
51
|
+
if (plan.payload?.implementationPlan?.length) {
|
|
52
|
+
implementationPlan.splice(0, implementationPlan.length, ...plan.payload.implementationPlan)
|
|
53
|
+
}
|
|
54
|
+
if (!plan.ok) issues.push(plan.details)
|
|
55
|
+
|
|
56
|
+
let lastVerification = []
|
|
57
|
+
let completed = false
|
|
58
|
+
for (let iteration = 1; iteration <= maxIterations && issues.length === 0; iteration += 1) {
|
|
59
|
+
const before = snapshot()
|
|
60
|
+
recordPhase('coder', 'running', 'Applying the approved implementation plan.', iteration)
|
|
61
|
+
const coder = await runAgent('coder', { phase: 'coder', input, implementationPlan, iteration, previousVerification: lastVerification })
|
|
62
|
+
phases[phases.length - 1] = { name: 'coder', iteration, status: coder.ok ? 'passed' : 'failed', details: coder.details }
|
|
63
|
+
|
|
64
|
+
for (const file of changedFiles(before, snapshot())) {
|
|
65
|
+
fileChanges.push({ file, change: `Changed during coder iteration ${iteration}.` })
|
|
66
|
+
}
|
|
67
|
+
if (!coder.ok) {
|
|
68
|
+
issues.push(coder.details)
|
|
69
|
+
break
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
recordPhase('tester', 'running', 'Running required verification gates.', iteration)
|
|
73
|
+
const verification = await runVerification()
|
|
74
|
+
const externalTest = await runAgent('tester', { phase: 'tester', input, implementationPlan, verification, iteration })
|
|
75
|
+
if (!externalTest.ok) verification.push({ command: 'external tester', result: 'fail', details: externalTest.details })
|
|
76
|
+
lastVerification = verification
|
|
77
|
+
const testerOk = verification.every((check) => check.result === 'pass')
|
|
78
|
+
&& externalTest.ok
|
|
79
|
+
&& externalTest.payload.approved === true
|
|
80
|
+
phases[phases.length - 1] = {
|
|
81
|
+
name: 'tester',
|
|
82
|
+
iteration,
|
|
83
|
+
status: testerOk ? 'passed' : 'failed',
|
|
84
|
+
details: testerOk ? 'All required gates passed and tester approved the evidence.'
|
|
85
|
+
: externalTest.ok ? 'At least one required gate failed or tester rejected the evidence.' : externalTest.details,
|
|
86
|
+
}
|
|
87
|
+
writeVerification(iteration, verification)
|
|
88
|
+
if (!testerOk && iteration === maxIterations) {
|
|
89
|
+
issues.push('Verification did not pass before maxIterations was reached.')
|
|
90
|
+
break
|
|
91
|
+
}
|
|
92
|
+
if (!testerOk) continue
|
|
93
|
+
|
|
94
|
+
recordPhase('reviewer', 'running', 'Checking scope, output contract, and acceptance criteria.', iteration)
|
|
95
|
+
const outOfScopeChanges = findOutOfScopeChanges(fileChanges.map(({ file }) => file), policy)
|
|
96
|
+
const reviewer = await runAgent('reviewer', { phase: 'reviewer', input, implementationPlan, verification, fileChanges, iteration })
|
|
97
|
+
const reviewerApproved = reviewer.ok && reviewer.payload?.approved === true && outOfScopeChanges.length === 0
|
|
98
|
+
const reviewerDetails = outOfScopeChanges.length
|
|
99
|
+
? `Out-of-scope files changed: ${outOfScopeChanges.join(', ')}`
|
|
100
|
+
: reviewer.details
|
|
101
|
+
phases[phases.length - 1] = { name: 'reviewer', iteration, status: reviewerApproved ? 'passed' : 'failed', details: reviewerDetails }
|
|
102
|
+
if (!reviewerApproved) {
|
|
103
|
+
issues.push(reviewerDetails)
|
|
104
|
+
break
|
|
105
|
+
}
|
|
106
|
+
completed = true
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
completed,
|
|
112
|
+
implementationPlan,
|
|
113
|
+
fileChanges,
|
|
114
|
+
verification: lastVerification,
|
|
115
|
+
issues,
|
|
116
|
+
phases,
|
|
117
|
+
iterations: phases.filter((phase) => phase.name === 'coder').length,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const detectPackageManager = (root) => {
|
|
5
|
+
if (existsSync(join(root, 'pnpm-lock.yaml'))) return { name: 'pnpm', command: 'pnpm', args: ['run'] }
|
|
6
|
+
if (existsSync(join(root, 'yarn.lock'))) return { name: 'yarn', command: 'yarn', args: [] }
|
|
7
|
+
return { name: 'npm', command: 'npm', args: ['run'] }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const packageScriptCommand = (root, script) => {
|
|
11
|
+
const manager = detectPackageManager(root)
|
|
12
|
+
return {
|
|
13
|
+
...manager,
|
|
14
|
+
args: [...manager.args, script],
|
|
15
|
+
display: `${manager.command} ${[...manager.args, script].join(' ')}`,
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/policy.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const validatePolicy = (policy) => {
|
|
2
|
+
if (!policy || typeof policy !== 'object') return 'Policy must be an object.'
|
|
3
|
+
if (!Array.isArray(policy.allowedProductPaths) || policy.allowedProductPaths.length === 0) {
|
|
4
|
+
return 'Harness policy must define at least one allowedProductPaths entry.'
|
|
5
|
+
}
|
|
6
|
+
if (!Number.isInteger(policy.maxIterations) || policy.maxIterations < 1) {
|
|
7
|
+
return 'Harness policy maxIterations must be a positive integer.'
|
|
8
|
+
}
|
|
9
|
+
if (!Array.isArray(policy.protectedPaths)) return 'Harness policy protectedPaths must be an array.'
|
|
10
|
+
if (!Array.isArray(policy.requiredChecks)) return 'Harness policy requiredChecks must be an array.'
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const findOutOfScopeChanges = (files, policy) => files.filter((file) =>
|
|
15
|
+
!policy.allowedProductPaths.some((allowedPath) => file.startsWith(allowedPath)),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
export const isCommandAllowed = (command, policy) =>
|
|
19
|
+
!policy.allowedAgentCommands?.length || policy.allowedAgentCommands.includes(command)
|
package/src/provider.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { parseAgentResponse, validateStageResponse } from './agent.mjs'
|
|
2
|
+
import { runCommand } from './command.mjs'
|
|
3
|
+
|
|
4
|
+
export const createProviderRunner = ({ root, agents, policy, validator, ajv }) => async (name, payload) => {
|
|
5
|
+
const config = agents[name]
|
|
6
|
+
if (!config || config.mode === 'internal') {
|
|
7
|
+
return { ok: true, details: `${name} completed using the built-in stage.`, payload: {} }
|
|
8
|
+
}
|
|
9
|
+
if (config.mode !== 'external' || typeof config.provider !== 'string') {
|
|
10
|
+
return { ok: false, details: `${name} requires a configured provider in .harness/agents.json.`, payload: {} }
|
|
11
|
+
}
|
|
12
|
+
const provider = agents.providers?.[config.provider]
|
|
13
|
+
if (!provider || typeof provider.command !== 'string' || !Array.isArray(provider.args)) {
|
|
14
|
+
return { ok: false, details: `${name} provider '${config.provider}' is not configured.`, payload: {} }
|
|
15
|
+
}
|
|
16
|
+
if (!isAllowed(provider.command, policy)) {
|
|
17
|
+
return { ok: false, details: `${name} provider command is not in policy.allowedAgentCommands.`, payload: {} }
|
|
18
|
+
}
|
|
19
|
+
const result = await runCommand(root, provider.command, provider.args, { ...payload, provider: config.provider })
|
|
20
|
+
if (result.code !== 0) {
|
|
21
|
+
return { ok: false, details: result.stderr.trim() || `${name} exited with code ${result.code}.`, payload: {} }
|
|
22
|
+
}
|
|
23
|
+
const response = parseAgentResponse(name, result.stdout, validator, ajv)
|
|
24
|
+
if (!response.ok) return response
|
|
25
|
+
const stageError = validateStageResponse(name, response.payload)
|
|
26
|
+
return stageError
|
|
27
|
+
? { ok: false, details: stageError, payload: response.payload }
|
|
28
|
+
: response
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isAllowed = (command, policy) =>
|
|
32
|
+
!policy.allowedAgentCommands?.length || policy.allowedAgentCommands.includes(command)
|
package/src/schema.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Ajv2020 from 'ajv/dist/2020.js'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export const loadSchemas = (root) => {
|
|
6
|
+
const read = (name) => JSON.parse(readFileSync(join(root, '.harness', name), 'utf8'))
|
|
7
|
+
return {
|
|
8
|
+
input: read('input.schema.json'),
|
|
9
|
+
output: read('output.schema.json'),
|
|
10
|
+
agentResponse: read('agent-response.schema.json'),
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const createValidators = (schemas) => {
|
|
15
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false })
|
|
16
|
+
return {
|
|
17
|
+
ajv,
|
|
18
|
+
input: ajv.compile(schemas.input),
|
|
19
|
+
output: ajv.compile(schemas.output),
|
|
20
|
+
agentResponse: ajv.compile(schemas.agentResponse),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const validationDetails = (ajv, validator) => ajv.errorsText(validator.errors)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const listFiles = (root, directory = root, result = []) => {
|
|
5
|
+
if (!existsSync(directory)) return result
|
|
6
|
+
for (const entry of readdirSync(directory)) {
|
|
7
|
+
if (directory === root && ['node_modules', 'dist', '.git'].includes(entry)) continue
|
|
8
|
+
const path = join(directory, entry)
|
|
9
|
+
if (statSync(path).isDirectory()) listFiles(root, path, result)
|
|
10
|
+
else result.push(relative(root, path).replaceAll('\\', '/'))
|
|
11
|
+
}
|
|
12
|
+
return result
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const snapshotFiles = (root) => new Map(
|
|
16
|
+
listFiles(root).map((file) => [file, readFileSync(join(root, file), 'utf8')]),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
export const changedFiles = (before, after) => [...new Set([...before.keys(), ...after.keys()])]
|
|
20
|
+
.filter((file) => !before.has(file) || !after.has(file) || before.get(file) !== after.get(file))
|