@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 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
+ }
@@ -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)
@@ -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))