From 9a1ba84560268f20c1d44be99b0d1527ecb116b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 04:34:12 +0000 Subject: [PATCH] feat: scaffold TypeScript WorkGraph PI harness CLI Co-authored-by: G9Pedro --- .gitignore | 3 + package-lock.json | 614 ++++++++++++++++++++++++++++++++++++++++ package.json | 27 ++ src/cli.ts | 308 ++++++++++++++++++++ src/gather/index.ts | 3 + src/gather/model.ts | 112 ++++++++ src/gather/service.ts | 66 +++++ src/gather/types.ts | 118 ++++++++ src/harness/index.ts | 3 + src/harness/results.ts | 39 +++ src/harness/runner.ts | 201 +++++++++++++ src/harness/types.ts | 46 +++ src/metrics/health.ts | 124 ++++++++ src/metrics/index.ts | 1 + src/workgraph/client.ts | 198 +++++++++++++ src/workgraph/index.ts | 2 + src/workgraph/types.ts | 62 ++++ tsconfig.json | 25 ++ 18 files changed, 1952 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 src/gather/index.ts create mode 100644 src/gather/model.ts create mode 100644 src/gather/service.ts create mode 100644 src/gather/types.ts create mode 100644 src/harness/index.ts create mode 100644 src/harness/results.ts create mode 100644 src/harness/runner.ts create mode 100644 src/harness/types.ts create mode 100644 src/metrics/health.ts create mode 100644 src/metrics/index.ts create mode 100644 src/workgraph/client.ts create mode 100644 src/workgraph/index.ts create mode 100644 src/workgraph/types.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c25e1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.log diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fba9e10 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,614 @@ +{ + "name": "workgraph-pi-harness-cli", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "workgraph-pi-harness-cli", + "version": "0.1.0", + "dependencies": { + "yaml": "^2.8.1" + }, + "bin": { + "workgraph-pi": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^24.4.0", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ec7ccdb --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "workgraph-pi-harness-cli", + "version": "0.1.0", + "description": "Node 20+ ESM TypeScript CLI for WorkGraph context gathering and autonomous harness loops.", + "private": true, + "type": "module", + "engines": { + "node": ">=20.0.0" + }, + "bin": { + "workgraph-pi": "./dist/cli.js" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node ./dist/cli.js", + "dev": "tsx ./src/cli.ts", + "check": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "yaml": "^2.8.1" + }, + "devDependencies": { + "@types/node": "^24.4.0", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..1cc8fa5 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,308 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import { parseArgs } from "node:util"; + +import YAML from "yaml"; + +import { gatherContext, previewEmptyBusinessYaml } from "./gather/index.js"; +import { HarnessRunner } from "./harness/index.js"; +import { calculateHealthScore, inputsFromHygiene } from "./metrics/index.js"; +import { WorkgraphClient } from "./workgraph/index.js"; +import type { WorkgraphHygieneReport } from "./workgraph/index.js"; + +async function main(): Promise { + const [command, ...rest] = process.argv.slice(2); + + switch (command) { + case "gather": + await handleGather(rest); + break; + case "run": + await handleRun(rest); + break; + case "status": + await handleStatus(rest); + break; + case "health": + await handleHealth(rest); + break; + case "help": + case "--help": + case "-h": + case undefined: + printHelp(); + break; + default: + throw new Error(`Unknown command "${command}". Run with --help.`); + } +} + +async function handleGather(argv: string[]): Promise { + const parsed = parseArgs({ + args: argv, + options: { + output: { type: "string", default: "business.yaml" }, + discovery: { type: "string", multiple: true }, + interview: { type: "string", multiple: true }, + "print-template": { type: "boolean", default: false }, + }, + strict: true, + allowPositionals: false, + }); + + if (parsed.values["print-template"]) { + process.stdout.write(previewEmptyBusinessYaml()); + return; + } + + const result = await gatherContext({ + outputPath: parsed.values.output, + discoveryFiles: parsed.values.discovery ?? [], + interviewFiles: parsed.values.interview ?? [], + }); + + process.stdout.write( + [ + `Wrote ${result.outputPath}`, + `Gaps: ${result.gapCount}`, + result.gapCount > 0 + ? `Missing fields: ${result.missingFields.join(", ")}` + : "Missing fields: none", + ].join("\n") + "\n", + ); + + if (result.interviewQuestions.length > 0) { + process.stdout.write("\nSuggested interview questions:\n"); + for (const question of result.interviewQuestions) { + process.stdout.write(`- ${question}\n`); + } + } +} + +async function handleRun(argv: string[]): Promise { + const parsed = parseArgs({ + args: argv, + options: { + business: { type: "string", default: "business.yaml" }, + results: { type: "string", default: "results.tsv" }, + interval: { type: "string", default: "60" }, + "max-cycles": { type: "string" }, + once: { type: "boolean", default: false }, + mcporter: { type: "string", default: "mcporter" }, + verbose: { type: "boolean", default: false }, + "threads-completed-today": { type: "string", default: "0" }, + "revenue-threads-completed": { type: "string", default: "0" }, + "automation-triggers-fired": { type: "string", default: "0" }, + }, + strict: true, + allowPositionals: false, + }); + + const intervalSeconds = toInt(parsed.values.interval, "--interval"); + const maxCycles = parsed.values["max-cycles"] + ? toInt(parsed.values["max-cycles"], "--max-cycles") + : undefined; + + const log = parsed.values.verbose + ? (line: string) => { + process.stderr.write(`${line}\n`); + } + : undefined; + + const client = new WorkgraphClient({ + mcporterPath: parsed.values.mcporter, + ...(log ? { log } : {}), + }); + + const runner = new HarnessRunner(client, { + businessYamlPath: parsed.values.business, + resultsPath: parsed.values.results, + intervalMs: intervalSeconds * 1_000, + ...(maxCycles !== undefined ? { maxCycles } : {}), + ...(log ? { log } : {}), + extras: { + threadsCompletedToday: toInt( + parsed.values["threads-completed-today"], + "--threads-completed-today", + ), + revenueThreadsCompleted: toInt( + parsed.values["revenue-threads-completed"], + "--revenue-threads-completed", + ), + automationTriggersFired: toInt( + parsed.values["automation-triggers-fired"], + "--automation-triggers-fired", + ), + }, + }); + + if (parsed.values.once) { + const observation = await runner.runOnce(1); + process.stdout.write( + `Cycle complete. Health score: ${observation.health.clampedScore}\n`, + ); + return; + } + + process.stdout.write("Starting autonomous loop.\n"); + await runner.runLoop(); +} + +async function handleStatus(argv: string[]): Promise { + const parsed = parseArgs({ + args: argv, + options: { + mcporter: { type: "string", default: "mcporter" }, + }, + strict: true, + allowPositionals: false, + }); + + const client = new WorkgraphClient({ + mcporterPath: parsed.values.mcporter, + }); + const [status, hygiene] = await Promise.all([ + client.status(), + client.graphHygiene(), + ]); + + process.stdout.write( + `${JSON.stringify({ status, hygiene }, null, 2)}\n`, + ); +} + +async function handleHealth(argv: string[]): Promise { + const parsed = parseArgs({ + args: argv, + options: { + hygiene: { type: "string" }, + "orphan-links": { type: "string", default: "0" }, + "stale-threads": { type: "string", default: "0" }, + "blocked-threads": { type: "string", default: "0" }, + "overdue-threads": { type: "string", default: "0" }, + "missing-evidence": { type: "string", default: "0" }, + "threads-completed-today": { type: "string", default: "0" }, + "revenue-threads-completed": { type: "string", default: "0" }, + "automation-triggers-fired": { type: "string", default: "0" }, + json: { type: "boolean", default: false }, + }, + strict: true, + allowPositionals: false, + }); + + let inputs; + if (parsed.values.hygiene) { + const hygiene = await readStructuredFile( + parsed.values.hygiene, + ); + inputs = inputsFromHygiene(hygiene, { + threadsCompletedToday: toInt( + parsed.values["threads-completed-today"], + "--threads-completed-today", + ), + revenueThreadsCompleted: toInt( + parsed.values["revenue-threads-completed"], + "--revenue-threads-completed", + ), + automationTriggersFired: toInt( + parsed.values["automation-triggers-fired"], + "--automation-triggers-fired", + ), + }); + } else { + inputs = { + orphanLinks: toInt(parsed.values["orphan-links"], "--orphan-links"), + staleThreads: toInt(parsed.values["stale-threads"], "--stale-threads"), + blockedThreads: toInt(parsed.values["blocked-threads"], "--blocked-threads"), + overdueThreads: toInt(parsed.values["overdue-threads"], "--overdue-threads"), + missingEvidence: toInt(parsed.values["missing-evidence"], "--missing-evidence"), + threadsCompletedToday: toInt( + parsed.values["threads-completed-today"], + "--threads-completed-today", + ), + revenueThreadsCompleted: toInt( + parsed.values["revenue-threads-completed"], + "--revenue-threads-completed", + ), + automationTriggersFired: toInt( + parsed.values["automation-triggers-fired"], + "--automation-triggers-fired", + ), + }; + } + + const score = calculateHealthScore(inputs); + + if (parsed.values.json) { + process.stdout.write(`${JSON.stringify(score, null, 2)}\n`); + return; + } + + process.stdout.write(`Health score: ${score.clampedScore}\n`); + process.stdout.write(`Raw score: ${score.rawScore}\n`); + process.stdout.write( + `Penalties total: ${ + score.penalties.orphanLinks + + score.penalties.staleThreads + + score.penalties.blockedThreads + + score.penalties.overdueThreads + + score.penalties.missingEvidence + }\n`, + ); + process.stdout.write( + `Rewards total: ${ + score.rewards.threadsCompletedToday + + score.rewards.revenueThreadsCompleted + + score.rewards.automationTriggersFired + }\n`, + ); +} + +function printHelp(): void { + const help = ` +WorkGraph PI Harness CLI + +Usage: + workgraph-pi [options] + +Commands: + gather Merge discovery/interview inputs and write business.yaml + run Start autonomous harness loop + status Print workgraph status + hygiene + health Compute graph health score + +Examples: + workgraph-pi gather --discovery relay.yaml --interview owner-notes.yaml + workgraph-pi run --once --business business.yaml + workgraph-pi status + workgraph-pi health --overdue-threads 2 --blocked-threads 1 +`; + process.stdout.write(help.trimStart()); + process.stdout.write("\n"); +} + +function toInt(value: string, flagName: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid integer for ${flagName}: ${value}`); + } + return parsed; +} + +async function readStructuredFile(filePath: string): Promise { + const content = await readFile(filePath, "utf8"); + if (filePath.endsWith(".json")) { + return JSON.parse(content) as T; + } + if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) { + return YAML.parse(content) as T; + } + throw new Error(`Unsupported file extension for ${filePath}; use json/yaml.`); +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Error: ${message}\n`); + process.exitCode = 1; +}); diff --git a/src/gather/index.ts b/src/gather/index.ts new file mode 100644 index 0000000..523dde4 --- /dev/null +++ b/src/gather/index.ts @@ -0,0 +1,3 @@ +export * from "./types.js"; +export * from "./model.js"; +export * from "./service.js"; diff --git a/src/gather/model.ts b/src/gather/model.ts new file mode 100644 index 0000000..c2ca3d9 --- /dev/null +++ b/src/gather/model.ts @@ -0,0 +1,112 @@ +import type { BusinessYaml } from "./types.js"; + +export function createEmptyBusinessModel(): BusinessYaml { + return { + business: {}, + people: { + owners: [], + employees: [], + contractors: [], + ai_agents: [], + }, + clients: [], + revenue_streams: [], + recurring_operations: {}, + tools: [], + current_state: {}, + goals: {}, + }; +} + +export function mergeBusinessYaml( + base: BusinessYaml, + patch: Partial, +): BusinessYaml { + return deepMerge(base, patch) as BusinessYaml; +} + +export function collectMissingFields(model: BusinessYaml): string[] { + const missing: string[] = []; + + if (!model.business.name) missing.push("business.name"); + if (!model.business.type) missing.push("business.type"); + if (!model.business.jurisdiction) missing.push("business.jurisdiction"); + if (!model.business.stage) missing.push("business.stage"); + if (model.people.owners.length === 0) missing.push("people.owners[0]"); + if (model.clients.length === 0) missing.push("clients[0]"); + if (model.revenue_streams.length === 0) missing.push("revenue_streams[0]"); + if (Object.keys(model.recurring_operations).length === 0) { + missing.push("recurring_operations"); + } + if (model.tools.length === 0) missing.push("tools[0]"); + if (!model.goals["30_day"]?.length) missing.push("goals.30_day"); + if (!model.goals["90_day"]?.length) missing.push("goals.90_day"); + if (!model.goals["12_month"]?.length) missing.push("goals.12_month"); + + return missing; +} + +function deepMerge(base: T, patch: unknown): T { + if (patch === undefined) { + return base; + } + + if (Array.isArray(base) && Array.isArray(patch)) { + return mergeArray(base, patch) as T; + } + + if (isPlainObject(base) && isPlainObject(patch)) { + const merged: Record = { ...base }; + + for (const [key, patchValue] of Object.entries(patch)) { + const currentValue = merged[key]; + if (currentValue === undefined) { + merged[key] = patchValue; + } else { + merged[key] = deepMerge(currentValue, patchValue); + } + } + + return merged as T; + } + + return patch as T; +} + +function mergeArray(base: unknown[], patch: unknown[]): unknown[] { + if (base.length === 0) { + return [...patch]; + } + + if (base.every(isNamedObject) && patch.every(isNamedObject)) { + const map = new Map>(); + for (const item of base as Record[]) { + map.set(String(item.name), item); + } + for (const item of patch as Record[]) { + const name = String(item.name); + const existing = map.get(name); + map.set(name, existing ? deepMerge(existing, item) : item); + } + return Array.from(map.values()); + } + + const serialized = new Set(base.map((item) => JSON.stringify(item))); + const merged = [...base]; + for (const item of patch) { + const key = JSON.stringify(item); + if (!serialized.has(key)) { + serialized.add(key); + merged.push(item); + } + } + return merged; +} + +function isNamedObject(value: unknown): value is { name: string } { + return isPlainObject(value) && typeof value.name === "string"; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/gather/service.ts b/src/gather/service.ts new file mode 100644 index 0000000..6239bca --- /dev/null +++ b/src/gather/service.ts @@ -0,0 +1,66 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import YAML from "yaml"; + +import { collectMissingFields, createEmptyBusinessModel, mergeBusinessYaml } from "./model.js"; +import type { BusinessYaml, GatherOptions, GatherResult } from "./types.js"; + +export async function gatherContext(options: GatherOptions): Promise { + let model = createEmptyBusinessModel(); + + for (const filePath of options.discoveryFiles ?? []) { + const payload = await readBusinessPatchFile(filePath); + model = mergeBusinessYaml(model, payload); + } + + for (const filePath of options.interviewFiles ?? []) { + const payload = await readBusinessPatchFile(filePath); + model = mergeBusinessYaml(model, payload); + } + + const outputPath = path.resolve(options.outputPath); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, YAML.stringify(model), "utf8"); + + const missingFields = collectMissingFields(model); + const interviewQuestions = buildInterviewQuestions(missingFields); + + return { + outputPath, + gapCount: missingFields.length, + missingFields, + interviewQuestions, + model, + }; +} + +export function previewEmptyBusinessYaml(): string { + return YAML.stringify(createEmptyBusinessModel()); +} + +async function readBusinessPatchFile(filePath: string): Promise> { + const fullPath = path.resolve(filePath); + const content = await readFile(fullPath, "utf8"); + const extension = path.extname(fullPath).toLowerCase(); + + try { + if (extension === ".json") { + return JSON.parse(content) as Partial; + } + if (extension === ".yaml" || extension === ".yml") { + return YAML.parse(content) as Partial; + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown parsing error"; + throw new Error(`Failed to parse gather input at ${fullPath}: ${message}`); + } + + throw new Error( + `Unsupported gather input extension for ${fullPath}. Use .yaml/.yml/.json.`, + ); +} + +function buildInterviewQuestions(missingFields: string[]): string[] { + return missingFields.map((field) => `Please provide ${field}.`); +} diff --git a/src/gather/types.ts b/src/gather/types.ts new file mode 100644 index 0000000..e560961 --- /dev/null +++ b/src/gather/types.ts @@ -0,0 +1,118 @@ +export interface ContactInfo { + email?: string; + phone?: string; + telegram?: string; + slack?: string; + [key: string]: string | undefined; +} + +export interface Person { + name: string; + role?: string; + authority?: string; + pay_method?: string; + pay_amount?: string; + tools?: string[]; + produces?: string[]; + contact?: ContactInfo; +} + +export interface Client { + name: string; + contact?: string; + deliverable?: string; + billing?: string; + status?: string; + relationship_health?: string; +} + +export interface RevenueStream { + name: string; + type?: string; + value?: string; + cadence?: string; + status?: string; + clients?: string[]; + delivery_mechanism?: string; +} + +export interface RecurringOperations { + daily?: string[]; + weekly?: string[]; + monthly?: string[]; + quarterly?: string[]; + annual?: string[]; +} + +export interface ToolSystem { + name: string; + purpose?: string; + used_by?: string[]; + access?: string; + cost?: string; + automation?: string; +} + +export interface CurrentState { + urgent?: string[]; + blocked?: string[]; + pipeline?: string[]; + financial?: { + cash_position?: string; + outstanding_ar?: string[]; + outstanding_ap?: string[]; + burn_rate?: string; + }; + team_capacity?: string[]; +} + +export interface Goals { + "30_day"?: string[]; + "90_day"?: string[]; + "12_month"?: string[]; +} + +export interface BusinessProfile { + name?: string; + type?: string; + jurisdiction?: string; + stage?: string; + mission?: string; + revenue_model?: string; + size?: string; +} + +export interface BusinessYaml { + business: BusinessProfile; + people: { + owners: Person[]; + employees: Person[]; + contractors: Person[]; + ai_agents: Person[]; + }; + clients: Client[]; + revenue_streams: RevenueStream[]; + recurring_operations: RecurringOperations; + tools: ToolSystem[]; + current_state: CurrentState; + goals: Goals; +} + +export interface GatherInput { + source: string; + payload: Partial; +} + +export interface GatherOptions { + outputPath: string; + discoveryFiles?: string[]; + interviewFiles?: string[]; +} + +export interface GatherResult { + outputPath: string; + gapCount: number; + missingFields: string[]; + interviewQuestions: string[]; + model: BusinessYaml; +} diff --git a/src/harness/index.ts b/src/harness/index.ts new file mode 100644 index 0000000..c5f552e --- /dev/null +++ b/src/harness/index.ts @@ -0,0 +1,3 @@ +export * from "./types.js"; +export * from "./results.js"; +export * from "./runner.js"; diff --git a/src/harness/results.ts b/src/harness/results.ts new file mode 100644 index 0000000..e3e6784 --- /dev/null +++ b/src/harness/results.ts @@ -0,0 +1,39 @@ +import { access, appendFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { mkdir } from "node:fs/promises"; + +import type { ExperimentRecord } from "./types.js"; + +const TSV_HEADER = "timestamp\texperiment\ttype\tstatus\tmetric\tdescription\n"; + +export async function ensureResultsTsv(resultsPath: string): Promise { + const fullPath = path.resolve(resultsPath); + try { + await access(fullPath); + } catch { + await mkdir(path.dirname(fullPath), { recursive: true }); + await writeFile(fullPath, TSV_HEADER, "utf8"); + } +} + +export async function appendResultRow( + resultsPath: string, + record: ExperimentRecord, +): Promise { + const fullPath = path.resolve(resultsPath); + await ensureResultsTsv(fullPath); + const safeDescription = sanitize(record.description); + const row = [ + record.timestamp, + record.experiment, + record.type, + record.status, + String(record.metric), + safeDescription, + ].join("\t"); + await appendFile(fullPath, `${row}\n`, "utf8"); +} + +function sanitize(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} diff --git a/src/harness/runner.ts b/src/harness/runner.ts new file mode 100644 index 0000000..e890460 --- /dev/null +++ b/src/harness/runner.ts @@ -0,0 +1,201 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import YAML from "yaml"; + +import { + collectMissingFields, + createEmptyBusinessModel, + mergeBusinessYaml, +} from "../gather/model.js"; +import { calculateHealthScore, inputsFromHygiene } from "../metrics/health.js"; +import { WorkgraphClient } from "../workgraph/client.js"; +import { appendResultRow, ensureResultsTsv } from "./results.js"; +import type { + ExperimentRecord, + ExperimentStatus, + ExperimentType, + HarnessRunnerOptions, + ObservationSnapshot, +} from "./types.js"; + +export class HarnessRunner { + private readonly client: WorkgraphClient; + private readonly options: HarnessRunnerOptions; + private readonly log: (line: string) => void; + + constructor(client: WorkgraphClient, options: HarnessRunnerOptions) { + this.client = client; + this.options = options; + this.log = options.log ?? (() => undefined); + } + + async runOnce(cycle = 1): Promise { + await this.ensurePrerequisites(); + const observation = await this.observe(); + const decision = this.decide(observation, cycle); + await this.executeDecision(decision.type, observation); + await appendResultRow(this.options.resultsPath, decision.record); + this.log( + `[harness] cycle=${cycle} health=${observation.health.clampedScore} decision=${decision.record.type}/${decision.record.status}`, + ); + return observation; + } + + async runLoop(): Promise { + const maxCycles = this.options.maxCycles; + let cycle = 1; + // When maxCycles is undefined, this intentionally runs forever. + while (!maxCycles || cycle <= maxCycles) { + try { + await this.runOnce(cycle); + } catch (error) { + await this.recordFailure(cycle, error); + } + + cycle += 1; + if (maxCycles && cycle > maxCycles) { + break; + } + await sleep(this.options.intervalMs ?? 60_000); + } + } + + private async ensurePrerequisites(): Promise { + await ensureResultsTsv(this.options.resultsPath); + await this.ensureBusinessYaml(); + await this.client.status(); + } + + private async ensureBusinessYaml(): Promise { + const businessPath = path.resolve(this.options.businessYamlPath); + let parsed: unknown; + try { + const content = await readFile(businessPath, "utf8"); + parsed = YAML.parse(content); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown read error"; + throw new Error( + `business.yaml is required before running the harness. Failed at ${businessPath}: ${message}`, + ); + } + + const model = mergeBusinessYaml( + createEmptyBusinessModel(), + (parsed ?? {}) as Record, + ); + const missingFields = collectMissingFields(model); + if (missingFields.length > 0) { + throw new Error( + `business.yaml is incomplete. Missing: ${missingFields.join(", ")}.`, + ); + } + } + + private async observe(): Promise { + const [status, hygiene, ledger, threads] = await Promise.all([ + this.client.status(), + this.client.graphHygiene(), + this.client.ledgerRecent(20), + this.client.listThreads(), + ]); + + const healthInputs = inputsFromHygiene(hygiene, this.options.extras); + const health = calculateHealthScore(healthInputs); + + return { + status, + hygiene, + ledger, + threads, + health, + }; + } + + private decide( + observation: ObservationSnapshot, + cycle: number, + ): { type: ExperimentType; record: ExperimentRecord } { + const overdueThreads = observation.health.penalties.overdueThreads / 5; + const blockedThreads = observation.health.penalties.blockedThreads / 3; + const staleThreads = observation.health.penalties.staleThreads / 2; + const orphanLinks = observation.health.penalties.orphanLinks / 0.5; + + let type: ExperimentType = "self-improve"; + let status: ExperimentStatus = "keep"; + let description = "Healthy graph. Running strategic optimization checks."; + + if (observation.threads.length === 0) { + type = "bootstrap"; + status = "iterate"; + description = + "No threads found. Bootstrap plan should map business.yaml into WorkGraph primitives."; + } else if (overdueThreads > 0 || blockedThreads > 0) { + type = "operational"; + status = "iterate"; + description = + "Urgent items detected (blocked/overdue). Prioritize remediation and escalation."; + } else if (staleThreads > 0 || orphanLinks > 0) { + type = "system"; + status = "iterate"; + description = + "Graph hygiene issues detected (stale/orphan). Prioritize cleanup and automation tuning."; + } + + return { + type, + record: { + timestamp: new Date().toISOString(), + experiment: `cycle-${cycle}`, + type, + status, + metric: observation.health.clampedScore, + description, + }, + }; + } + + private async executeDecision( + type: ExperimentType, + observation: ObservationSnapshot, + ): Promise { + if (type === "bootstrap") { + await this.client.query({ type: "project", limit: 50 }); + return; + } + + if (type === "operational") { + await this.client.listThreads({ status: "open", readyOnly: true }); + return; + } + + if (type === "system") { + await this.client.graphHygiene(); + return; + } + + // For healthy states, collect additional context from recent activity. + if (observation.ledger.length > 0) { + await this.client.ledgerRecent(20); + } + } + + private async recordFailure(cycle: number, error: unknown): Promise { + const message = error instanceof Error ? error.message : "Unknown harness error"; + this.log(`[harness] cycle=${cycle} failed: ${message}`); + await appendResultRow(this.options.resultsPath, { + timestamp: new Date().toISOString(), + experiment: `cycle-${cycle}`, + type: "system", + status: "failed", + metric: 0, + description: message, + }); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/src/harness/types.ts b/src/harness/types.ts new file mode 100644 index 0000000..f785a73 --- /dev/null +++ b/src/harness/types.ts @@ -0,0 +1,46 @@ +import type { HealthScoreBreakdown } from "../metrics/health.js"; +import type { + WorkgraphHygieneReport, + WorkgraphLedgerEvent, + WorkgraphStatusSnapshot, + WorkgraphThread, +} from "../workgraph/types.js"; + +export type ExperimentType = + | "bootstrap" + | "operational" + | "system" + | "code" + | "self-improve"; + +export type ExperimentStatus = "keep" | "discard" | "iterate" | "failed"; + +export interface HarnessRunnerOptions { + businessYamlPath: string; + resultsPath: string; + intervalMs?: number; + maxCycles?: number; + log?: (line: string) => void; + extras?: { + threadsCompletedToday?: number; + revenueThreadsCompleted?: number; + automationTriggersFired?: number; + }; +} + +export interface ObservationSnapshot { + status: WorkgraphStatusSnapshot; + hygiene: WorkgraphHygieneReport; + ledger: WorkgraphLedgerEvent[]; + threads: WorkgraphThread[]; + health: HealthScoreBreakdown; +} + +export interface ExperimentRecord { + timestamp: string; + experiment: string; + type: ExperimentType; + status: ExperimentStatus; + metric: number; + description: string; +} diff --git a/src/metrics/health.ts b/src/metrics/health.ts new file mode 100644 index 0000000..5ae69e5 --- /dev/null +++ b/src/metrics/health.ts @@ -0,0 +1,124 @@ +import type { WorkgraphHygieneReport } from "../workgraph/types.js"; + +export interface HealthScoreInputs { + orphanLinks: number; + staleThreads: number; + blockedThreads: number; + overdueThreads: number; + missingEvidence: number; + threadsCompletedToday: number; + revenueThreadsCompleted: number; + automationTriggersFired: number; +} + +export interface HealthScoreBreakdown { + base: number; + penalties: { + orphanLinks: number; + staleThreads: number; + blockedThreads: number; + overdueThreads: number; + missingEvidence: number; + }; + rewards: { + threadsCompletedToday: number; + revenueThreadsCompleted: number; + automationTriggersFired: number; + }; + rawScore: number; + clampedScore: number; +} + +export function calculateHealthScore( + inputs: HealthScoreInputs, + options: { min?: number; max?: number } = {}, +): HealthScoreBreakdown { + const base = 100; + const penalties = { + orphanLinks: inputs.orphanLinks * 0.5, + staleThreads: inputs.staleThreads * 2, + blockedThreads: inputs.blockedThreads * 3, + overdueThreads: inputs.overdueThreads * 5, + missingEvidence: inputs.missingEvidence * 2, + }; + const rewards = { + threadsCompletedToday: inputs.threadsCompletedToday * 1, + revenueThreadsCompleted: inputs.revenueThreadsCompleted * 3, + automationTriggersFired: inputs.automationTriggersFired * 0.5, + }; + + const rawScore = + base - + penalties.orphanLinks - + penalties.staleThreads - + penalties.blockedThreads - + penalties.overdueThreads - + penalties.missingEvidence + + rewards.threadsCompletedToday + + rewards.revenueThreadsCompleted + + rewards.automationTriggersFired; + + const min = options.min ?? 0; + const max = options.max ?? 200; + + return { + base, + penalties, + rewards, + rawScore, + clampedScore: clamp(rawScore, min, max), + }; +} + +export function inputsFromHygiene( + hygiene: WorkgraphHygieneReport, + extras: Partial< + Pick< + HealthScoreInputs, + "threadsCompletedToday" | "revenueThreadsCompleted" | "automationTriggersFired" + > + > = {}, +): HealthScoreInputs { + return { + orphanLinks: toNumber( + hygiene.orphanLinks ?? + hygiene.orphan_links ?? + hygiene.orphans ?? + hygiene.orphanCount, + ), + staleThreads: toNumber( + hygiene.staleThreads ?? hygiene.stale_threads ?? hygiene.staleCount, + ), + blockedThreads: toNumber( + hygiene.blockedThreads ?? hygiene.blocked_threads ?? hygiene.blockedCount, + ), + overdueThreads: toNumber( + hygiene.overdueThreads ?? hygiene.overdue_threads ?? hygiene.overdueCount, + ), + missingEvidence: toNumber( + hygiene.missingEvidence ?? + hygiene.missing_evidence ?? + hygiene.missingEvidenceCount, + ), + threadsCompletedToday: toNumber(extras.threadsCompletedToday ?? 0), + revenueThreadsCompleted: toNumber(extras.revenueThreadsCompleted ?? 0), + automationTriggersFired: toNumber(extras.automationTriggersFired ?? 0), + }; +} + +function toNumber(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return 0; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} diff --git a/src/metrics/index.ts b/src/metrics/index.ts new file mode 100644 index 0000000..737b757 --- /dev/null +++ b/src/metrics/index.ts @@ -0,0 +1 @@ +export * from "./health.js"; diff --git a/src/workgraph/client.ts b/src/workgraph/client.ts new file mode 100644 index 0000000..f5c5950 --- /dev/null +++ b/src/workgraph/client.ts @@ -0,0 +1,198 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import type { + JsonValue, + WorkgraphCallResult, + WorkgraphHygieneReport, + WorkgraphLedgerEvent, + WorkgraphQueryFilters, + WorkgraphStatusSnapshot, + WorkgraphThread, + WorkgraphThreadListFilters, +} from "./types.js"; + +const execFileAsync = promisify(execFile); + +export interface WorkgraphClientOptions { + mcporterPath?: string; + cwd?: string; + timeoutMs?: number; + maxBufferBytes?: number; + log?: (line: string) => void; +} + +export class WorkgraphClient { + private readonly mcporterPath: string; + private readonly cwd: string | undefined; + private readonly timeoutMs: number; + private readonly maxBufferBytes: number; + private readonly log: ((line: string) => void) | undefined; + + constructor(options: WorkgraphClientOptions = {}) { + this.mcporterPath = options.mcporterPath ?? "mcporter"; + this.cwd = options.cwd; + this.timeoutMs = options.timeoutMs ?? 30_000; + this.maxBufferBytes = options.maxBufferBytes ?? 1024 * 1024 * 8; + this.log = options.log; + } + + async call( + method: string, + args: Record = {}, + ): Promise> { + const fullMethod = method.startsWith("workgraph.") + ? method + : `workgraph.${method}`; + const argv = ["call", fullMethod, ...this.serializeArgs(args)]; + + this.log?.( + `[workgraph] ${this.mcporterPath} ${argv + .map((part) => JSON.stringify(part)) + .join(" ")}`, + ); + + try { + const { stdout, stderr } = await execFileAsync(this.mcporterPath, argv, { + cwd: this.cwd, + timeout: this.timeoutMs, + maxBuffer: this.maxBufferBytes, + }); + + if (stderr.trim()) { + this.log?.(`[workgraph:stderr] ${stderr.trim()}`); + } + + const data = this.parseOutput(stdout); + return { + method: fullMethod, + args, + data, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown workgraph error"; + throw new Error( + `Failed to invoke ${fullMethod} through mcporter: ${message}`, + ); + } + } + + async status(): Promise { + const result = await this.call("workgraph_status"); + return result.data; + } + + async graphHygiene(): Promise { + const result = await this.call( + "workgraph_graph_hygiene", + ); + return result.data; + } + + async listThreads( + filters: WorkgraphThreadListFilters = {}, + ): Promise { + const result = await this.call( + "workgraph_thread_list", + filters as Record, + ); + return result.data; + } + + async query(filters: WorkgraphQueryFilters = {}): Promise { + const result = await this.call( + "workgraph_query", + filters as Record, + ); + return result.data; + } + + async ledgerRecent(count = 20, actor?: string): Promise { + const result = await this.call( + "workgraph_ledger_recent", + { count, actor }, + ); + return result.data; + } + + async threadClaim(threadPath: string, actor?: string): Promise { + const result = await this.call("workgraph_thread_claim", { + threadPath, + actor, + }); + return result.data; + } + + async threadDone( + threadPath: string, + output?: string, + evidence?: string[], + actor?: string, + ): Promise { + const result = await this.call("workgraph_thread_done", { + threadPath, + output, + evidence, + actor, + }); + return result.data; + } + + private serializeArgs(args: Record): string[] { + return Object.entries(args) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${this.serializeValue(value)}`); + } + + private serializeValue(value: unknown): string { + if (value === null) { + return "null"; + } + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return String(value); + } + return JSON.stringify(value); + } + + private parseOutput(stdout: string): T { + const trimmed = stdout.trim(); + if (!trimmed) { + return {} as T; + } + + const direct = this.tryParseJson(trimmed); + if (direct.ok) { + return direct.value as T; + } + + const lines = trimmed.split("\n").map((line) => line.trim()); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]; + if (!line) { + continue; + } + const parsed = this.tryParseJson(line); + if (parsed.ok) { + return parsed.value as T; + } + } + + return trimmed as T; + } + + private tryParseJson(input: string): { ok: true; value: unknown } | { ok: false } { + try { + return { + ok: true, + value: JSON.parse(input), + }; + } catch { + return { ok: false }; + } + } +} diff --git a/src/workgraph/index.ts b/src/workgraph/index.ts new file mode 100644 index 0000000..46815ad --- /dev/null +++ b/src/workgraph/index.ts @@ -0,0 +1,2 @@ +export * from "./client.js"; +export * from "./types.js"; diff --git a/src/workgraph/types.ts b/src/workgraph/types.ts new file mode 100644 index 0000000..208ae76 --- /dev/null +++ b/src/workgraph/types.ts @@ -0,0 +1,62 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; +export interface JsonObject { + [key: string]: JsonValue; +} + +export interface WorkgraphStatusSnapshot extends JsonObject { + workspace?: string; + actor?: string; + healthy?: boolean; +} + +export interface WorkgraphThread extends JsonObject { + path?: string; + title?: string; + status?: string; + priority?: string; + updatedAt?: string; +} + +export interface WorkgraphHygieneReport extends JsonObject { + orphanLinks?: number; + staleThreads?: number; + blockedThreads?: number; + overdueThreads?: number; + missingEvidence?: number; + [key: string]: JsonValue; +} + +export interface WorkgraphLedgerEvent extends JsonObject { + id?: string; + type?: string; + actor?: string; + timestamp?: string; +} + +export interface WorkgraphQueryFilters { + type?: string; + status?: string; + owner?: string; + tag?: string; + text?: string; + pathIncludes?: string; + updatedAfter?: string; + updatedBefore?: string; + createdAfter?: string; + createdBefore?: string; + limit?: number; + offset?: number; +} + +export interface WorkgraphThreadListFilters { + status?: string; + readyOnly?: boolean; + space?: string; +} + +export interface WorkgraphCallResult { + method: string; + args: Record; + data: T; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..08c1066 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "dist", + "node_modules" + ] +}