@ps-neko/nekowork 0.1.0-alpha.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.
Files changed (203) hide show
  1. package/AGENTS.md +112 -0
  2. package/CLAUDE.md +81 -0
  3. package/LICENSE +21 -0
  4. package/README.md +283 -0
  5. package/REVIEW.md +96 -0
  6. package/RULES.md +51 -0
  7. package/SOUL.md +21 -0
  8. package/WORKING-CONTEXT.md +52 -0
  9. package/agent.yaml +219 -0
  10. package/agents/architect.md +57 -0
  11. package/agents/code-reviewer.md +60 -0
  12. package/agents/codex-challenger.md +53 -0
  13. package/agents/codex-reviewer.md +56 -0
  14. package/agents/debugger.md +33 -0
  15. package/agents/doc-writer.md +51 -0
  16. package/agents/executor.md +41 -0
  17. package/agents/planner.md +49 -0
  18. package/agents/research.md +50 -0
  19. package/agents/security-reviewer.md +47 -0
  20. package/agents/test-engineer.md +41 -0
  21. package/bridge/mcp-server.js +301 -0
  22. package/commands/claude-led-codex-review.md +29 -0
  23. package/docs/ADVANCED.md +321 -0
  24. package/docs/AI-DEVELOPMENT-LIFECYCLE.md +105 -0
  25. package/docs/ARCHITECTURE.md +205 -0
  26. package/docs/AUDIT.md +114 -0
  27. package/docs/AUTH-MIGRATION.md +282 -0
  28. package/docs/CHANGELOG.md +97 -0
  29. package/docs/CLI-STAGES.md +89 -0
  30. package/docs/CODEMAPS/README.md +15 -0
  31. package/docs/CODEMAPS/agents.md +22 -0
  32. package/docs/CODEMAPS/bridge.md +18 -0
  33. package/docs/CODEMAPS/hooks.md +28 -0
  34. package/docs/CODEMAPS/manifests.md +14 -0
  35. package/docs/CODEMAPS/rules.md +22 -0
  36. package/docs/CODEMAPS/schemas.md +21 -0
  37. package/docs/CODEMAPS/scripts.md +158 -0
  38. package/docs/CODEMAPS/skills.md +29 -0
  39. package/docs/CODEMAPS/tests.md +98 -0
  40. package/docs/CORE-INVARIANTS.md +38 -0
  41. package/docs/DEMO.md +110 -0
  42. package/docs/EXAMPLE-PROJECT.md +92 -0
  43. package/docs/PORTING.md +154 -0
  44. package/docs/PRODUCT-PRINCIPLES.md +303 -0
  45. package/docs/PUBLISH-ALPHA.md +106 -0
  46. package/docs/QUICKSTART.md +344 -0
  47. package/docs/RELEASE-READINESS.md +140 -0
  48. package/docs/RISK-CLASSIFIER.md +50 -0
  49. package/docs/RUNBOOK.md +146 -0
  50. package/docs/SECURITY.md +79 -0
  51. package/docs/SETUP.md +142 -0
  52. package/docs/WHY-NEKOWORK.md +64 -0
  53. package/docs/case-studies/README.md +16 -0
  54. package/docs/case-studies/SINDRESORHUS-IS-PLAIN-OBJ.md +141 -0
  55. package/docs/dev-log/2026-04-29-p1-recovery.md +142 -0
  56. package/docs/dev-log/2026-04-29-week1-4.md +81 -0
  57. package/docs/examples/GITHUB-ACTIONS-HARDENING.md +86 -0
  58. package/docs/examples/QUALITY-LIFECYCLE-SMOKE.md +32 -0
  59. package/docs/examples/TRADING-DASHBOARD-MOCK.md +65 -0
  60. package/docs/workflows-stash/README.md +32 -0
  61. package/docs/workflows-stash/harness-review.yml +166 -0
  62. package/docs/workflows-stash/harness-validate.yml +48 -0
  63. package/examples/github-actions-hardening/.github/workflows/hardened-validate.yml +38 -0
  64. package/examples/github-actions-hardening/README.md +31 -0
  65. package/examples/github-actions-hardening/case-study/ASK.md +26 -0
  66. package/examples/github-actions-hardening/case-study/GATE_STATUS.md +28 -0
  67. package/examples/github-actions-hardening/case-study/PLAN.md +25 -0
  68. package/examples/github-actions-hardening/case-study/SHIP_READY.md +21 -0
  69. package/examples/github-actions-hardening/case-study/TASK.md +30 -0
  70. package/examples/github-actions-hardening/case-study/TEAM_HANDOFFS.md +37 -0
  71. package/examples/github-actions-hardening/case-study/VERIFY_SUMMARY.md +35 -0
  72. package/examples/github-actions-hardening/case-study/WORK_SUMMARY.md +24 -0
  73. package/examples/github-actions-hardening/package.json +12 -0
  74. package/examples/github-actions-hardening/scripts/check.mjs +43 -0
  75. package/examples/quality-lifecycle-smoke/README.md +30 -0
  76. package/examples/quality-lifecycle-smoke/case-study/ASK.md +24 -0
  77. package/examples/quality-lifecycle-smoke/case-study/GATE_STATUS.md +10 -0
  78. package/examples/quality-lifecycle-smoke/case-study/PLAN.md +19 -0
  79. package/examples/quality-lifecycle-smoke/case-study/SHIP_READY.md +11 -0
  80. package/examples/quality-lifecycle-smoke/case-study/TASK.md +19 -0
  81. package/examples/quality-lifecycle-smoke/case-study/TEAM_HANDOFFS.md +21 -0
  82. package/examples/quality-lifecycle-smoke/case-study/VERIFY_SUMMARY.md +44 -0
  83. package/examples/quality-lifecycle-smoke/case-study/WORK_SUMMARY.md +19 -0
  84. package/examples/quality-lifecycle-smoke/package.json +8 -0
  85. package/examples/quality-lifecycle-smoke/scripts/check.mjs +44 -0
  86. package/examples/trading-dashboard-mock/README.md +33 -0
  87. package/examples/trading-dashboard-mock/case-study/ASK.md +24 -0
  88. package/examples/trading-dashboard-mock/case-study/GATE_STATUS.md +28 -0
  89. package/examples/trading-dashboard-mock/case-study/PLAN.md +23 -0
  90. package/examples/trading-dashboard-mock/case-study/SHIP_READY.md +21 -0
  91. package/examples/trading-dashboard-mock/case-study/TASK.md +29 -0
  92. package/examples/trading-dashboard-mock/case-study/TEAM_HANDOFFS.md +49 -0
  93. package/examples/trading-dashboard-mock/case-study/VERIFY_SUMMARY.md +35 -0
  94. package/examples/trading-dashboard-mock/case-study/WORK_SUMMARY.md +27 -0
  95. package/examples/trading-dashboard-mock/fixtures/market.json +9 -0
  96. package/examples/trading-dashboard-mock/index.html +76 -0
  97. package/examples/trading-dashboard-mock/package.json +9 -0
  98. package/examples/trading-dashboard-mock/scripts/check.mjs +54 -0
  99. package/examples/trading-dashboard-mock/src/app.js +83 -0
  100. package/examples/trading-dashboard-mock/src/styles.css +227 -0
  101. package/hooks/hooks.json +44 -0
  102. package/hooks/scripts/config-protection.js +34 -0
  103. package/hooks/scripts/gateguard-fact-force.js +146 -0
  104. package/hooks/scripts/persistent-mode.mjs +27 -0
  105. package/hooks/scripts/pre-bash-dispatcher.js +63 -0
  106. package/hooks/scripts/quality-gate.js +106 -0
  107. package/manifests/install-components.json +195 -0
  108. package/manifests/install-modules.json +101 -0
  109. package/manifests/install-profiles.json +134 -0
  110. package/package.json +96 -0
  111. package/rules/common/coding-style.md +71 -0
  112. package/rules/common/security.md +69 -0
  113. package/rules/common/testing.md +58 -0
  114. package/rules/python/coding-style.md +80 -0
  115. package/rules/python/testing.md +86 -0
  116. package/rules/typescript/coding-style.md +97 -0
  117. package/rules/typescript/security.md +67 -0
  118. package/rules/typescript/testing.md +78 -0
  119. package/schemas/agent-yaml.schema.json +168 -0
  120. package/schemas/agent.schema.json +32 -0
  121. package/schemas/handoff.schema.json +105 -0
  122. package/schemas/hooks.schema.json +35 -0
  123. package/schemas/install-components.schema.json +46 -0
  124. package/schemas/install-modules.schema.json +39 -0
  125. package/schemas/install-profiles.schema.json +32 -0
  126. package/schemas/install-state.schema.json +42 -0
  127. package/schemas/routing.schema.json +42 -0
  128. package/schemas/skill.schema.json +19 -0
  129. package/scripts/agents/dispatch.js +144 -0
  130. package/scripts/agents/runners/claude.js +214 -0
  131. package/scripts/agents/runners/codex.js +233 -0
  132. package/scripts/agents/runners/gemini.js +92 -0
  133. package/scripts/agents/runners/mock.js +107 -0
  134. package/scripts/auth/github-import-gh.js +52 -0
  135. package/scripts/auth/github-login.js +79 -0
  136. package/scripts/auth/github-logout.js +21 -0
  137. package/scripts/auth/github-status.js +46 -0
  138. package/scripts/build-claude.js +101 -0
  139. package/scripts/build-codemaps.js +286 -0
  140. package/scripts/build-codex.js +93 -0
  141. package/scripts/build-cursor.js +132 -0
  142. package/scripts/build-gemini.js +117 -0
  143. package/scripts/build-opencode.js +117 -0
  144. package/scripts/ci/catalog.js +120 -0
  145. package/scripts/ci/check-markers.js +48 -0
  146. package/scripts/ci/security-hardening.js +270 -0
  147. package/scripts/ci/validate-agents.js +88 -0
  148. package/scripts/ci/validate-hooks.js +99 -0
  149. package/scripts/ci/validate-manifests.js +128 -0
  150. package/scripts/ci/validate-skills.js +93 -0
  151. package/scripts/cli.js +1134 -0
  152. package/scripts/core/auth-guard.js +22 -0
  153. package/scripts/core/build-roots.js +11 -0
  154. package/scripts/core/cli-resolver.js +64 -0
  155. package/scripts/core/execution-workspace.js +84 -0
  156. package/scripts/core/git-mutation-guard.js +79 -0
  157. package/scripts/core/install-state.js +125 -0
  158. package/scripts/core/json-extractor.js +32 -0
  159. package/scripts/core/subprocess.js +74 -0
  160. package/scripts/daemon/wait.js +278 -0
  161. package/scripts/demo-external-project.js +222 -0
  162. package/scripts/demo-quick-run.js +193 -0
  163. package/scripts/demo-review.js +204 -0
  164. package/scripts/doctor.js +296 -0
  165. package/scripts/install-apply.js +185 -0
  166. package/scripts/install-plan.js +411 -0
  167. package/scripts/lib/acceptance-criteria.js +105 -0
  168. package/scripts/lib/costs.js +82 -0
  169. package/scripts/lib/instincts.js +194 -0
  170. package/scripts/lib/keychain.js +85 -0
  171. package/scripts/lib/profile-policy.js +134 -0
  172. package/scripts/lib/profile-safety.js +81 -0
  173. package/scripts/lib/risk-classifier.js +145 -0
  174. package/scripts/lib/router.js +138 -0
  175. package/scripts/lib/severity.js +99 -0
  176. package/scripts/lib/token-vault.js +136 -0
  177. package/scripts/orchestrators/apply.js +225 -0
  178. package/scripts/orchestrators/ask.js +143 -0
  179. package/scripts/orchestrators/gate.js +179 -0
  180. package/scripts/orchestrators/ralph.js +179 -0
  181. package/scripts/orchestrators/review.js +452 -0
  182. package/scripts/orchestrators/run.js +151 -0
  183. package/scripts/orchestrators/ship.js +339 -0
  184. package/scripts/orchestrators/team-lite.js +270 -0
  185. package/scripts/orchestrators/team.js +244 -0
  186. package/scripts/orchestrators/verify.js +306 -0
  187. package/scripts/orchestrators/work.js +207 -0
  188. package/scripts/portability/simulate-port.js +220 -0
  189. package/scripts/repair.js +184 -0
  190. package/scripts/sync-claude-md.js +220 -0
  191. package/scripts/verify/claude-live.js +30 -0
  192. package/scripts/verify/codex-live.js +60 -0
  193. package/scripts/verify/gemini-live.js +48 -0
  194. package/scripts/verify/runtime.js +105 -0
  195. package/skills/claude-led-codex-review/SKILL.md +133 -0
  196. package/skills/plan-eng-review/SKILL.md +51 -0
  197. package/skills/porting/SKILL.md +69 -0
  198. package/skills/ralph/SKILL.md +48 -0
  199. package/skills/release-readiness/SKILL.md +62 -0
  200. package/skills/review/SKILL.md +42 -0
  201. package/skills/security-hardening/SKILL.md +59 -0
  202. package/skills/ship/SKILL.md +44 -0
  203. package/skills/tdd-workflow/SKILL.md +42 -0
@@ -0,0 +1,22 @@
1
+ const BLOCKED_ENV = {
2
+ claude: ['ANTHROPIC_API_KEY'],
3
+ codex: ['OPENAI_API_KEY'],
4
+ gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'],
5
+ };
6
+
7
+ export function assertDelegatedCliAuth(provider, env = process.env) {
8
+ if (env.HARNESS_AUTH_ALLOW_ENV_OVERRIDE === '1') return;
9
+
10
+ const keys = BLOCKED_ENV[provider] || [];
11
+ const found = keys.filter((key) => env[key]);
12
+ if (!found.length) return;
13
+
14
+ throw new Error([
15
+ `구독/OAuth 보호: ${provider} CLI 호출 직전 API key 환경변수가 감지되었습니다.`,
16
+ `감지: ${found.join(', ')}`,
17
+ '로컬 CLI auth를 쓰려면 해당 환경변수를 unset 하세요.',
18
+ '종량제 사용을 의도했다면 HARNESS_AUTH_ALLOW_ENV_OVERRIDE=1 을 명시하세요.',
19
+ ].join('\n'));
20
+ }
21
+
22
+ export { BLOCKED_ENV };
@@ -0,0 +1,11 @@
1
+ import path from 'node:path';
2
+
3
+ export function buildRoots(defaultRoot) {
4
+ const sourceRoot = path.resolve(process.env.HARNESS_SOURCE_ROOT || defaultRoot);
5
+ const targetRoot = path.resolve(
6
+ process.env.HARNESS_TARGET_ROOT
7
+ || process.env.HARNESS_PROJECT_ROOT
8
+ || sourceRoot,
9
+ );
10
+ return { sourceRoot, targetRoot };
11
+ }
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export function resolveCli(bin, env = process.env, options = {}) {
5
+ const platform = options.platform || process.platform;
6
+ const sep = platform === 'win32' ? ';' : ':';
7
+ const pathDirs = (env.PATH || '').split(sep).filter(Boolean);
8
+
9
+ const exts = platform === 'win32'
10
+ ? preferredWindowsExtensions(env)
11
+ : [''];
12
+
13
+ for (const dir of pathDirs) {
14
+ for (const ext of exts) {
15
+ const full = path.join(dir, bin + ext);
16
+ if (fs.existsSync(full)) return full;
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+
22
+ export function resolveProviderCli(provider, options = {}) {
23
+ const bin = options.bin || provider;
24
+ const env = options.env || process.env;
25
+ const root = options.root || process.cwd();
26
+ const roots = options.roots || [root];
27
+ const resolved = resolveCli(bin, env, options);
28
+ if (!resolved) return null;
29
+ for (const trustRoot of roots) {
30
+ assertProviderCliTrust(provider, resolved, trustRoot, env);
31
+ }
32
+ return resolved;
33
+ }
34
+
35
+ export function assertProviderCliTrust(provider, binPath, root = process.cwd(), env = process.env) {
36
+ const providerKey = provider.toUpperCase().replace(/[^A-Z0-9]/g, '_');
37
+ const allowWorkspaceBin =
38
+ env.HARNESS_CLI_ALLOW_WORKSPACE_BIN === '1'
39
+ || env[`HARNESS_${providerKey}_ALLOW_WORKSPACE_BIN`] === '1';
40
+
41
+ if (!allowWorkspaceBin && isPathInside(root, binPath)) {
42
+ throw new Error([
43
+ `${provider} CLI resolved inside the current workspace: ${binPath}`,
44
+ 'Provider CLIs should come from a user/global install so local project files cannot hijack delegated auth.',
45
+ `Move the CLI earlier on PATH outside this repo, or set HARNESS_${providerKey}_ALLOW_WORKSPACE_BIN=1 if this is intentional.`,
46
+ ].join('\n'));
47
+ }
48
+
49
+ return binPath;
50
+ }
51
+
52
+ export function isPathInside(root, target) {
53
+ const rel = path.relative(path.resolve(root), path.resolve(target));
54
+ return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
55
+ }
56
+
57
+ function preferredWindowsExtensions(env) {
58
+ const fromPathExt = (env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
59
+ .split(';')
60
+ .map((ext) => ext.trim().toLowerCase())
61
+ .filter(Boolean);
62
+ const preferred = ['.exe', '.cmd', '.bat', '.ps1', ''];
63
+ return [...new Set([...preferred, ...fromPathExt])];
64
+ }
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ export async function withExecutionWorkspace(root, sessionDir, fn, options = {}) {
7
+ const worktreeRoot = fs.mkdtempSync(path.join(os.tmpdir(), `harness-exec-${options.sessionId || 'session'}-`));
8
+ const keep = process.env.HARNESS_KEEP_EXECUTION_WORKTREE === '1';
9
+
10
+ runGit(root, ['worktree', 'add', '--detach', worktreeRoot, 'HEAD']);
11
+
12
+ try {
13
+ if (options.baseDiff) applyExecutionDiff(worktreeRoot, options.baseDiff);
14
+ const result = await fn(worktreeRoot);
15
+ const diff = captureExecutionDiff(worktreeRoot);
16
+ const files = changedFiles(worktreeRoot);
17
+ const diffPath = persistDiff(sessionDir, options.stage || 'implement', options.round || 1, diff);
18
+
19
+ return {
20
+ result,
21
+ worktreeRoot: keep ? worktreeRoot : null,
22
+ diff,
23
+ diffPath,
24
+ files,
25
+ };
26
+ } finally {
27
+ if (!keep) {
28
+ removeWorktree(root, worktreeRoot);
29
+ removeDir(worktreeRoot);
30
+ }
31
+ }
32
+ }
33
+
34
+ export function applyExecutionDiff(root, diff) {
35
+ if (!String(diff || '').trim()) return false;
36
+ const r = spawnSync('git', ['-C', root, 'apply', '--3way', '--whitespace=nowarn'], {
37
+ input: diff,
38
+ encoding: 'utf8',
39
+ windowsHide: true,
40
+ });
41
+ if (r.status !== 0) {
42
+ throw new Error(`git apply failed in ${root}\n${r.stderr || r.stdout}`);
43
+ }
44
+ return true;
45
+ }
46
+
47
+ export function captureExecutionDiff(worktreeRoot) {
48
+ // Intent-to-add makes untracked files visible in the captured patch.
49
+ runGit(worktreeRoot, ['add', '-N', '.'], { allowFailure: true });
50
+ return runGit(worktreeRoot, ['diff', '--binary', 'HEAD']).stdout;
51
+ }
52
+
53
+ export function changedFiles(worktreeRoot) {
54
+ const out = runGit(worktreeRoot, ['diff', '--name-only', 'HEAD']).stdout.trim();
55
+ return out ? out.split(/\r?\n/).filter(Boolean) : [];
56
+ }
57
+
58
+ function persistDiff(sessionDir, stage, round, diff) {
59
+ if (!sessionDir || !diff) return null;
60
+ const dir = path.join(sessionDir, 'diffs');
61
+ fs.mkdirSync(dir, { recursive: true });
62
+ const f = path.join(dir, `${String(round).padStart(2, '0')}-${stage}.diff`);
63
+ fs.writeFileSync(f, diff);
64
+ return f;
65
+ }
66
+
67
+ function removeWorktree(root, worktreeRoot) {
68
+ runGit(root, ['worktree', 'remove', '--force', worktreeRoot], { allowFailure: true });
69
+ }
70
+
71
+ function removeDir(dir) {
72
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
73
+ }
74
+
75
+ function runGit(cwd, args, options = {}) {
76
+ const r = spawnSync('git', ['-C', cwd, ...args], {
77
+ encoding: 'utf8',
78
+ windowsHide: true,
79
+ });
80
+ if (r.status !== 0 && !options.allowFailure) {
81
+ throw new Error(`git ${args.join(' ')} failed in ${cwd}\n${r.stderr || r.stdout}`);
82
+ }
83
+ return { stdout: r.stdout || '', stderr: r.stderr || '', status: r.status ?? 1 };
84
+ }
@@ -0,0 +1,79 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ export async function withGitMutationGuard(root, fn, options = {}) {
4
+ const label = options.label || 'process';
5
+ const allowEnvKey = options.allowEnvKey || 'HARNESS_ALLOW_WORKSPACE_MUTATION';
6
+ const env = options.env || process.env;
7
+
8
+ if (!root || env[allowEnvKey] === '1') {
9
+ return await fn();
10
+ }
11
+
12
+ const before = readGitStatus(root);
13
+ if (!before) {
14
+ return await fn();
15
+ }
16
+
17
+ let result;
18
+ let originalError;
19
+ try {
20
+ result = await fn();
21
+ } catch (e) {
22
+ originalError = e;
23
+ }
24
+
25
+ const after = readGitStatus(root);
26
+ if (after && before.raw !== after.raw) {
27
+ const msg = [
28
+ `${label} changed the git workspace during guarded execution.`,
29
+ 'This usually means a read-only review runner wrote files despite sandbox settings.',
30
+ `Set ${allowEnvKey}=1 only when this mutation is intentional.`,
31
+ '',
32
+ 'Before:',
33
+ before.text || '(clean)',
34
+ '',
35
+ 'After:',
36
+ after.text || '(clean)',
37
+ ];
38
+ if (originalError) {
39
+ msg.push('', 'Original error:', originalError.message || String(originalError));
40
+ }
41
+ const mutationError = new Error(msg.join('\n'));
42
+ if (originalError) mutationError.cause = originalError;
43
+ throw mutationError;
44
+ }
45
+
46
+ if (originalError) throw originalError;
47
+ return result;
48
+ }
49
+
50
+ export function readGitStatus(root) {
51
+ if (!isGitWorkTree(root)) return null;
52
+
53
+ const raw = runGit(root, ['status', '--porcelain=v1', '-z']);
54
+ const text = runGit(root, ['status', '--short']);
55
+ return { raw, text };
56
+ }
57
+
58
+ function isGitWorkTree(root) {
59
+ try {
60
+ const r = spawnSync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree'], {
61
+ encoding: 'utf8',
62
+ windowsHide: true,
63
+ });
64
+ return r.status === 0 && r.stdout.trim() === 'true';
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ function runGit(root, args) {
71
+ const r = spawnSync('git', ['-C', root, ...args], {
72
+ encoding: 'utf8',
73
+ windowsHide: true,
74
+ });
75
+ if (r.status !== 0) {
76
+ throw new Error(`git ${args.join(' ')} failed: ${r.stderr || r.stdout}`);
77
+ }
78
+ return r.stdout;
79
+ }
@@ -0,0 +1,125 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import Ajv2020 from 'ajv/dist/2020.js';
5
+ import addFormats from 'ajv-formats';
6
+
7
+ export const ZERO_SHA = '0'.repeat(64);
8
+
9
+ const CATALOG_INPUTS = ['agent.yaml', 'agents', 'skills', 'commands', 'hooks', 'manifests'];
10
+
11
+ export function installStatePath(root) {
12
+ return path.join(root, '.harness', 'install-state.json');
13
+ }
14
+
15
+ export function sha256(filePath) {
16
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
17
+ }
18
+
19
+ export function sha256OfDir(dir) {
20
+ if (!fs.existsSync(dir)) return null;
21
+ const entries = [];
22
+
23
+ function walk(d) {
24
+ for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
25
+ const p = path.join(d, e.name);
26
+ if (e.isDirectory()) walk(p);
27
+ else if (e.isFile()) {
28
+ const rel = path.relative(dir, p).replace(/\\/g, '/');
29
+ const h = crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
30
+ entries.push(`${rel} ${h}`);
31
+ }
32
+ }
33
+ }
34
+
35
+ walk(dir);
36
+ if (entries.length === 0) return null;
37
+ return crypto.createHash('sha256').update(entries.join('\n')).digest('hex');
38
+ }
39
+
40
+ export function sha256OfCatalog(root, inputs = CATALOG_INPUTS) {
41
+ const parts = [];
42
+ for (const inp of inputs) {
43
+ const p = path.join(root, inp);
44
+ if (!fs.existsSync(p)) continue;
45
+ const stat = fs.statSync(p);
46
+ if (stat.isFile()) parts.push(`${inp}\t${sha256(p)}`);
47
+ else parts.push(`${inp}/\t${sha256OfDir(p) || ''}`);
48
+ }
49
+ return crypto.createHash('sha256').update(parts.join('\n')).digest('hex');
50
+ }
51
+
52
+ export function loadInstallState(root) {
53
+ const file = installStatePath(root);
54
+ if (!fs.existsSync(file)) return null;
55
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
56
+ }
57
+
58
+ export function buildInstallState(root, {
59
+ targetRoot = root,
60
+ profile,
61
+ harnessDefs,
62
+ harnessNames,
63
+ previousState = null,
64
+ now = new Date().toISOString(),
65
+ } = {}) {
66
+ const sourceSha = sha256OfCatalog(root);
67
+ const selected = new Set(harnessNames || harnessDefs.map(h => h.name));
68
+ const state = {
69
+ $schema: 'schemas/install-state.schema.json',
70
+ version: previousState?.version || '0.0.1',
71
+ harness_version: packageVersion(root),
72
+ profile: profile || previousState?.profile || 'developer',
73
+ installed_at: previousState?.installed_at || now,
74
+ last_updated: now,
75
+ components: { ...(previousState?.components || {}) },
76
+ };
77
+
78
+ for (const h of harnessDefs) {
79
+ if (!selected.has(h.name)) continue;
80
+ const component = buildStateComponent(root, targetRoot, h, sourceSha, now, previousState?.components?.[h.name]);
81
+ if (component) state.components[h.name] = component;
82
+ }
83
+
84
+ assertInstallState(root, state);
85
+ return { state, sourceSha };
86
+ }
87
+
88
+ export function buildStateComponent(root, targetRoot, harnessDef, sourceSha, now = new Date().toISOString(), previous = null) {
89
+ const outDir = path.join(targetRoot, harnessDef.output_dir);
90
+ if (!fs.existsSync(outDir)) return null;
91
+ return {
92
+ installed_at: previous?.installed_at || now,
93
+ source_sha256: sourceSha,
94
+ targets: [{
95
+ harness: harnessDef.name,
96
+ path: harnessDef.output_dir,
97
+ sha256: sha256OfDir(outDir) || ZERO_SHA,
98
+ }],
99
+ };
100
+ }
101
+
102
+ export function writeInstallState(root, state, { schemaRoot = root } = {}) {
103
+ assertInstallState(schemaRoot, state);
104
+ const file = installStatePath(root);
105
+ fs.mkdirSync(path.dirname(file), { recursive: true });
106
+ fs.writeFileSync(file, JSON.stringify(state, null, 2));
107
+ return file;
108
+ }
109
+
110
+ export function assertInstallState(root, state) {
111
+ const schema = JSON.parse(fs.readFileSync(path.join(root, 'schemas', 'install-state.schema.json'), 'utf8'));
112
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
113
+ addFormats(ajv);
114
+ const validate = ajv.compile(schema);
115
+ if (!validate(state)) {
116
+ const detail = (validate.errors || [])
117
+ .map(e => `${e.instancePath || '/'} ${e.message}`)
118
+ .join('; ');
119
+ throw new Error(`install-state schema validation failed: ${detail}`);
120
+ }
121
+ }
122
+
123
+ function packageVersion(root) {
124
+ return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
125
+ }
@@ -0,0 +1,32 @@
1
+ export function extractJson(text) {
2
+ if (typeof text !== 'string') return null;
3
+ const m = text.match(/```json\s*([\s\S]*?)```/i);
4
+ if (m) return m[1].trim();
5
+ const start = text.indexOf('{');
6
+ if (start < 0) return null;
7
+
8
+ let depth = 0;
9
+ let inStr = false;
10
+ let esc = false;
11
+ for (let i = start; i < text.length; i++) {
12
+ const c = text[i];
13
+ if (inStr) {
14
+ if (esc) esc = false;
15
+ else if (c === '\\') esc = true;
16
+ else if (c === '"') inStr = false;
17
+ continue;
18
+ }
19
+ if (c === '"') { inStr = true; continue; }
20
+ if (c === '{') depth++;
21
+ else if (c === '}') {
22
+ depth--;
23
+ if (depth === 0) return text.slice(start, i + 1);
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ export function parseJsonObject(text) {
30
+ const json = extractJson(text);
31
+ return json ? JSON.parse(json) : null;
32
+ }
@@ -0,0 +1,74 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+
3
+ export function spawnAndCollect(bin, args, stdin, options = {}) {
4
+ const label = options.label || bin;
5
+ const timeoutMs = Number(options.timeoutMs || 180000);
6
+
7
+ return new Promise((resolve, reject) => {
8
+ const child = spawnProcess(bin, args, options);
9
+ let out = '';
10
+ let err = '';
11
+ let done = false;
12
+
13
+ const settle = (fn, value) => {
14
+ if (done) return;
15
+ done = true;
16
+ clearTimeout(timeout);
17
+ fn(value);
18
+ };
19
+
20
+ const timeout = setTimeout(() => {
21
+ killProcessTree(child);
22
+ settle(reject, new Error(`${label} timeout`));
23
+ }, timeoutMs);
24
+
25
+ child.stdout.on('data', (d) => (out += d.toString()));
26
+ child.stderr.on('data', (d) => (err += d.toString()));
27
+ child.on('error', (e) => settle(reject, e));
28
+ child.on('close', (code) => {
29
+ if (code !== 0) settle(reject, new Error(`${label} exit ${code}\nstderr:\n${err}`));
30
+ else settle(resolve, out);
31
+ });
32
+ child.stdin.end(stdin);
33
+ });
34
+ }
35
+
36
+ function killProcessTree(child) {
37
+ try {
38
+ if (process.platform === 'win32' && child.pid) {
39
+ spawnSync('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], {
40
+ stdio: 'ignore',
41
+ windowsHide: true,
42
+ });
43
+ return;
44
+ }
45
+ child.kill();
46
+ } catch {
47
+ try { child.kill(); } catch {}
48
+ }
49
+ }
50
+
51
+ function spawnProcess(bin, args, options = {}) {
52
+ const spawnOptions = {
53
+ stdio: ['pipe', 'pipe', 'pipe'],
54
+ cwd: options.cwd,
55
+ env: options.env,
56
+ };
57
+
58
+ if (process.platform !== 'win32') {
59
+ return spawn(bin, args, spawnOptions);
60
+ }
61
+
62
+ if (/\.(cmd|bat)$/i.test(bin)) {
63
+ const comspec = process.env.ComSpec || 'cmd.exe';
64
+ return spawn(comspec, ['/d', '/c', bin, ...args], spawnOptions);
65
+ }
66
+
67
+ if (/\.ps1$/i.test(bin)) {
68
+ return spawn('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', bin, ...args], {
69
+ ...spawnOptions,
70
+ });
71
+ }
72
+
73
+ return spawn(bin, args, spawnOptions);
74
+ }