@manhd_d/ai-harness 0.0.0-stage → 0.6.2
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/CHANGELOG.md +124 -0
- package/CONTRIBUTING.md +13 -0
- package/LICENSE +21 -0
- package/README.md +70 -2
- package/SECURITY.md +10 -0
- package/adapters/ai-python/adapter.yaml +46 -0
- package/adapters/ai-python/checks/verification.yaml +32 -0
- package/adapters/ai-python/evals/cases.json +25 -0
- package/adapters/ai-python/rules/architecture.md +3 -0
- package/adapters/ai-python/rules/database.md +3 -0
- package/adapters/ai-python/rules/performance.md +3 -0
- package/adapters/ai-python/rules/security.md +3 -0
- package/adapters/ai-python/rules.md +29 -0
- package/adapters/ai-python/skills/harness-ai-python-rag-access/SKILL.md +23 -0
- package/adapters/ai-python/skills/harness-ai-python-tool-safety/SKILL.md +23 -0
- package/adapters/astro/README.md +23 -0
- package/adapters/astro/adapter.yaml +31 -0
- package/adapters/astro/checks/verification.yaml +59 -0
- package/adapters/astro/evals/cases.json +45 -0
- package/adapters/astro/rules/architecture.md +9 -0
- package/adapters/astro/rules/deployment.md +9 -0
- package/adapters/astro/rules/performance.md +8 -0
- package/adapters/astro/rules/security.md +9 -0
- package/adapters/astro/rules.md +17 -0
- package/adapters/astro/skills/harness-astro-cloudflare/SKILL.md +27 -0
- package/adapters/astro/skills/harness-astro-islands/SKILL.md +28 -0
- package/adapters/astro/skills/harness-astro-microfrontend/SKILL.md +27 -0
- package/adapters/astro/skills/harness-astro-performance/SKILL.md +27 -0
- package/adapters/astro/skills/harness-astro-project-architecture/SKILL.md +28 -0
- package/adapters/astro/skills/harness-astro-seo/SKILL.md +28 -0
- package/adapters/java-spring/adapter.yaml +42 -0
- package/adapters/java-spring/checks/verification.yaml +33 -0
- package/adapters/java-spring/evals/cases.json +25 -0
- package/adapters/java-spring/rules/architecture.md +3 -0
- package/adapters/java-spring/rules/database.md +3 -0
- package/adapters/java-spring/rules/performance.md +3 -0
- package/adapters/java-spring/rules/security.md +3 -0
- package/adapters/java-spring/rules.md +34 -0
- package/adapters/java-spring/skills/harness-java-spring-database-migration/SKILL.md +23 -0
- package/adapters/java-spring/skills/harness-java-spring-kafka-outbox/SKILL.md +23 -0
- package/adapters/java-spring/skills/harness-java-spring-spring-api/SKILL.md +23 -0
- package/adapters/kubernetes/adapter.yaml +58 -0
- package/adapters/kubernetes/checks/verification.yaml +63 -0
- package/adapters/kubernetes/evals/cases.json +25 -0
- package/adapters/kubernetes/rules/architecture.md +5 -0
- package/adapters/kubernetes/rules/reliability.md +5 -0
- package/adapters/kubernetes/rules/security.md +5 -0
- package/adapters/kubernetes/rules.md +9 -0
- package/adapters/kubernetes/skills/harness-kubernetes-helm-release/SKILL.md +26 -0
- package/adapters/kubernetes/skills/harness-kubernetes-rollout-safety/SKILL.md +27 -0
- package/adapters/kubernetes/skills/harness-kubernetes-workload-review/SKILL.md +27 -0
- package/adapters/react-native/adapter.yaml +28 -0
- package/adapters/react-native/checks/verification.yaml +30 -0
- package/adapters/react-native/evals/cases.json +25 -0
- package/adapters/react-native/rules/architecture.md +3 -0
- package/adapters/react-native/rules/database.md +3 -0
- package/adapters/react-native/rules/performance.md +3 -0
- package/adapters/react-native/rules/security.md +3 -0
- package/adapters/react-native/rules.md +23 -0
- package/adapters/react-native/skills/harness-react-native-device-permissions/SKILL.md +23 -0
- package/adapters/react-native/skills/harness-react-native-offline-sync/SKILL.md +23 -0
- package/adapters/react-nextjs/adapter.yaml +28 -0
- package/adapters/react-nextjs/checks/verification.yaml +49 -0
- package/adapters/react-nextjs/evals/cases.json +25 -0
- package/adapters/react-nextjs/rules/architecture.md +3 -0
- package/adapters/react-nextjs/rules/database.md +3 -0
- package/adapters/react-nextjs/rules/performance.md +3 -0
- package/adapters/react-nextjs/rules/security.md +3 -0
- package/adapters/react-nextjs/rules.md +27 -0
- package/adapters/react-nextjs/skills/harness-react-nextjs-accessible-ui/SKILL.md +23 -0
- package/adapters/react-nextjs/skills/harness-react-nextjs-server-boundaries/SKILL.md +23 -0
- package/bin/aih.js +4 -0
- package/capabilities/devops/README.md +24 -0
- package/capabilities/devops/capability.json +20 -0
- package/capabilities/devops/checks/verification.json +17 -0
- package/capabilities/devops/evals/cases.json +25 -0
- package/capabilities/devops/skills/harness-devops-ci-cd/SKILL.md +27 -0
- package/capabilities/devops/skills/harness-devops-deployment/SKILL.md +27 -0
- package/capabilities/devops/skills/harness-devops-docker/SKILL.md +25 -0
- package/capabilities/devops/skills/harness-devops-incident-response/SKILL.md +27 -0
- package/capabilities/devops/skills/harness-devops-infrastructure/SKILL.md +28 -0
- package/capabilities/devops/skills/harness-devops-observability/SKILL.md +25 -0
- package/capabilities/devops/workflows/incident-recovery.md +12 -0
- package/capabilities/devops/workflows/pr-to-production.md +16 -0
- package/capabilities/monetization/README.md +40 -0
- package/capabilities/monetization/capability.json +25 -0
- package/capabilities/monetization/checks/verification.json +53 -0
- package/capabilities/monetization/evals/cases.json +45 -0
- package/capabilities/monetization/skills/harness-monetization-ad-placement-ux/SKILL.md +28 -0
- package/capabilities/monetization/skills/harness-monetization-adsense-integration/SKILL.md +29 -0
- package/capabilities/monetization/skills/harness-monetization-analytics-revenue/SKILL.md +28 -0
- package/capabilities/monetization/skills/harness-monetization-publisher-compliance/SKILL.md +28 -0
- package/capabilities/monetization/skills/harness-monetization-seo-growth/SKILL.md +28 -0
- package/capabilities/monetization/templates/ad-placement-review.md +31 -0
- package/capabilities/monetization/templates/analytics-report.md +38 -0
- package/capabilities/monetization/templates/privacy-consent-checklist.md +39 -0
- package/capabilities/monetization/templates/site-monetization-plan.md +46 -0
- package/capabilities/monetization/workflows/site-to-adsense.md +19 -0
- package/capabilities/monetization/workflows/traffic-to-revenue-review.md +14 -0
- package/capabilities/product/README.md +78 -0
- package/capabilities/product/agents/business-analyst.md +15 -0
- package/capabilities/product/agents/product-manager.md +15 -0
- package/capabilities/product/agents/product-owner.md +15 -0
- package/capabilities/product/agents/project-manager.md +15 -0
- package/capabilities/product/agents/requirements-reviewer.md +15 -0
- package/capabilities/product/capability.json +55 -0
- package/capabilities/product/checks/verification.json +41 -0
- package/capabilities/product/evals/cases.json +35 -0
- package/capabilities/product/skills/harness-product-acceptance-criteria/SKILL.md +25 -0
- package/capabilities/product/skills/harness-product-backlog-prioritization/SKILL.md +25 -0
- package/capabilities/product/skills/harness-product-brd-generation/SKILL.md +25 -0
- package/capabilities/product/skills/harness-product-business-analysis/SKILL.md +26 -0
- package/capabilities/product/skills/harness-product-document-review/SKILL.md +26 -0
- package/capabilities/product/skills/harness-product-idea-discovery/SKILL.md +27 -0
- package/capabilities/product/skills/harness-product-knowledge-analysis/SKILL.md +28 -0
- package/capabilities/product/skills/harness-product-prd-generation/SKILL.md +26 -0
- package/capabilities/product/skills/harness-product-product-research/SKILL.md +27 -0
- package/capabilities/product/skills/harness-product-srs-generation/SKILL.md +28 -0
- package/capabilities/product/skills/harness-product-traceability/SKILL.md +24 -0
- package/capabilities/product/skills/harness-product-use-case-generation/SKILL.md +26 -0
- package/capabilities/product/skills/harness-product-user-stories/SKILL.md +25 -0
- package/capabilities/product/templates/acceptance-criteria.md +27 -0
- package/capabilities/product/templates/backlog.md +23 -0
- package/capabilities/product/templates/brd.md +43 -0
- package/capabilities/product/templates/business-rules.md +27 -0
- package/capabilities/product/templates/decision-log.md +7 -0
- package/capabilities/product/templates/delivery-plan.md +18 -0
- package/capabilities/product/templates/idea.md +38 -0
- package/capabilities/product/templates/prd-lite.md +39 -0
- package/capabilities/product/templates/prd.md +36 -0
- package/capabilities/product/templates/product-brief.md +27 -0
- package/capabilities/product/templates/research.md +31 -0
- package/capabilities/product/templates/review-report.md +26 -0
- package/capabilities/product/templates/source-register.md +19 -0
- package/capabilities/product/templates/srs.md +46 -0
- package/capabilities/product/templates/traceability.md +23 -0
- package/capabilities/product/templates/use-cases.md +34 -0
- package/capabilities/product/workflows/change-impact-analysis.md +15 -0
- package/capabilities/product/workflows/confluence-to-spec.md +21 -0
- package/capabilities/product/workflows/document-driven.md +13 -0
- package/capabilities/product/workflows/idea-driven.md +13 -0
- package/capabilities/product/workflows/idea-to-prd.md +20 -0
- package/capabilities/uiux/README.md +21 -0
- package/capabilities/uiux/capability.json +23 -0
- package/capabilities/uiux/checks/verification.json +17 -0
- package/capabilities/uiux/design-system/tokens.example.json +21 -0
- package/capabilities/uiux/evals/cases.json +15 -0
- package/capabilities/uiux/integrations/confluence.md +10 -0
- package/capabilities/uiux/integrations/figma.md +10 -0
- package/capabilities/uiux/skills/harness-uiux-design-review/SKILL.md +24 -0
- package/capabilities/uiux/skills/harness-uiux-design-system/SKILL.md +24 -0
- package/capabilities/uiux/skills/harness-uiux-figma-design/SKILL.md +26 -0
- package/capabilities/uiux/skills/harness-uiux-requirements-analysis/SKILL.md +26 -0
- package/capabilities/uiux/skills/harness-uiux-screen-planning/SKILL.md +24 -0
- package/capabilities/uiux/skills/harness-uiux-user-flow/SKILL.md +25 -0
- package/capabilities/uiux/templates/requirements.md +7 -0
- package/capabilities/uiux/templates/screen-spec.md +15 -0
- package/capabilities/uiux/workflows/docs-to-figma.md +22 -0
- package/capabilities/uiux/workflows/idea-to-figma.md +11 -0
- package/cli/adapter-schema.js +42 -0
- package/cli/adapters.js +149 -0
- package/cli/capabilities.js +93 -0
- package/cli/composition.js +96 -0
- package/cli/evaluation-contract.js +40 -0
- package/cli/files.js +125 -0
- package/cli/harness.js +255 -0
- package/cli/main.js +119 -0
- package/cli/module-contract.js +29 -0
- package/core/agents/implementer.md +16 -0
- package/core/agents/planner.md +16 -0
- package/core/agents/reviewer.md +16 -0
- package/core/agents/security-reviewer.md +16 -0
- package/core/agents/test-engineer.md +16 -0
- package/core/checks/verification.json +28 -0
- package/core/evals/cases.json +25 -0
- package/core/evals/composition.json +33 -0
- package/core/policies/permissions.md +17 -0
- package/core/policies/security.md +15 -0
- package/core/rules/context.md +15 -0
- package/core/rules/foundations.md +24 -0
- package/core/skills/harness-ai-evaluation/SKILL.md +21 -0
- package/core/skills/harness-debugging/SKILL.md +20 -0
- package/core/skills/harness-review/SKILL.md +20 -0
- package/core/skills/harness-spec/SKILL.md +21 -0
- package/core/skills/harness-tdd/SKILL.md +20 -0
- package/core/workflows/bugfix.md +9 -0
- package/core/workflows/spec-to-code.md +19 -0
- package/core/workflows/upgrade.md +10 -0
- package/docs/adapters.md +83 -0
- package/docs/agent-handoffs.md +79 -0
- package/docs/architecture.md +107 -0
- package/docs/cli.md +109 -0
- package/docs/documentation-workflows.md +71 -0
- package/docs/end-to-end-workflow.md +116 -0
- package/docs/examples/ai-rag-service.md +60 -0
- package/docs/examples/astro-adsense-microtools.md +80 -0
- package/docs/examples/astro-micro-tools.md +125 -0
- package/docs/examples/document-driven-project.md +62 -0
- package/docs/examples/fullstack-web.md +80 -0
- package/docs/examples/idea-driven-project.md +71 -0
- package/docs/examples/react-native-app.md +57 -0
- package/docs/examples/spring-boot-api.md +100 -0
- package/docs/getting-started.md +100 -0
- package/docs/monetization.md +53 -0
- package/docs/npm-release.md +57 -0
- package/docs/openspec.md +23 -0
- package/docs/pull-request.md +37 -0
- package/docs/reference-analysis.md +22 -0
- package/docs/release-policy.md +17 -0
- package/docs/skill-matrix.md +134 -0
- package/docs/verification.md +20 -0
- package/docs/versioning.md +96 -0
- package/evals/README.md +45 -0
- package/evals/cases.json +41 -0
- package/harness.yaml +24 -0
- package/integrations/confluence/README.md +31 -0
- package/integrations/github/README.md +17 -0
- package/integrations/github/pull_request_template.md +22 -0
- package/integrations/gitlab/README.md +9 -0
- package/integrations/gitlab/harness-ci.yml +10 -0
- package/integrations/mcp/README.md +16 -0
- package/integrations/mcp/server-inventory.example.json +16 -0
- package/package.json +42 -4
- package/scripts/score-eval.js +40 -0
- package/templates/AGENTS.md +18 -0
- package/templates/openspec/.openspec.yaml +1 -0
- package/templates/openspec/config.yaml +14 -0
- package/templates/openspec/design.md +14 -0
- package/templates/openspec/proposal.md +19 -0
- package/templates/openspec/spec.md +8 -0
- package/templates/openspec/tasks.md +11 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { manifest, validateAdapters, validateCapabilities, HarnessError } from './files.js';
|
|
2
|
+
import { loadAdapter } from './adapter-schema.js';
|
|
3
|
+
import { loadCapability } from './capabilities.js';
|
|
4
|
+
import { moduleRef } from './module-contract.js';
|
|
5
|
+
|
|
6
|
+
export function resolveModules(source, { adapters = [], capabilities = [] } = {}) {
|
|
7
|
+
validateAdapters(adapters); validateCapabilities(capabilities);
|
|
8
|
+
const registry = manifest(source);
|
|
9
|
+
const unknownA = adapters.filter(id => !registry.adapters.includes(id));
|
|
10
|
+
const unknownC = capabilities.filter(id => !(registry.capabilities ?? []).includes(id));
|
|
11
|
+
if (unknownA.length || unknownC.length) {
|
|
12
|
+
throw new HarnessError(`Unknown modules: ${[
|
|
13
|
+
...unknownA.map(id => moduleRef('adapter', id)),
|
|
14
|
+
...unknownC.map(id => moduleRef('capability', id)),
|
|
15
|
+
].join(', ')}`);
|
|
16
|
+
}
|
|
17
|
+
const sortedA = [...adapters].sort(), sortedC = [...capabilities].sort();
|
|
18
|
+
const modules = [
|
|
19
|
+
...sortedA.map(id => ({ kind: 'adapter', id, data: loadAdapter(source, id) })),
|
|
20
|
+
...sortedC.map(id => ({ kind: 'capability', id, data: loadCapability(source, id) })),
|
|
21
|
+
];
|
|
22
|
+
const selected = new Map(modules.map(m => [moduleRef(m.kind, m.id), m]));
|
|
23
|
+
const declared = new Set([...registry.adapters.map(x => moduleRef('adapter', x)),
|
|
24
|
+
...(registry.capabilities ?? []).map(x => moduleRef('capability', x))]);
|
|
25
|
+
const warnings = [], graph = new Map();
|
|
26
|
+
for (const { kind, id, data } of modules) {
|
|
27
|
+
const owner = moduleRef(kind, id);
|
|
28
|
+
for (const field of ['requires', 'recommends', 'conflicts']) {
|
|
29
|
+
for (const ref of data[field] ?? []) {
|
|
30
|
+
if (!declared.has(ref)) throw new HarnessError(`Unknown ${field} reference ${ref} in ${owner}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const dep of data.requires ?? []) {
|
|
34
|
+
if (!selected.has(dep)) throw new HarnessError(`Missing required module ${dep} for ${owner}`);
|
|
35
|
+
}
|
|
36
|
+
for (const denied of data.conflicts ?? []) {
|
|
37
|
+
if (selected.has(denied)) throw new HarnessError(`Incompatible modules: ${owner} conflicts with ${denied}`);
|
|
38
|
+
}
|
|
39
|
+
if (data.compatible_adapters?.length) {
|
|
40
|
+
for (const adapter of sortedA) {
|
|
41
|
+
if (!data.compatible_adapters.includes(adapter)) {
|
|
42
|
+
throw new HarnessError(`Incompatible adapter ${adapter} for ${owner}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const hint of data.recommends ?? []) {
|
|
47
|
+
if (!selected.has(hint)) warnings.push(`${owner} recommends ${hint} (optional)`);
|
|
48
|
+
}
|
|
49
|
+
graph.set(owner, data.requires ?? []);
|
|
50
|
+
}
|
|
51
|
+
const states = new Map();
|
|
52
|
+
function visit(ref) {
|
|
53
|
+
if (states.get(ref) === 'visiting') throw new HarnessError(`Module dependency cycle at ${ref}`);
|
|
54
|
+
if (states.get(ref) === 'done') return;
|
|
55
|
+
states.set(ref, 'visiting');
|
|
56
|
+
for (const dep of graph.get(ref) ?? []) visit(dep);
|
|
57
|
+
states.set(ref, 'done');
|
|
58
|
+
}
|
|
59
|
+
for (const ref of graph.keys()) visit(ref);
|
|
60
|
+
return { status: 'ok', adapters: sortedA, capabilities: sortedC,
|
|
61
|
+
modules: modules.map(m => ({ id: m.id, kind: m.kind, module_version: m.data.module_version ?? null })),
|
|
62
|
+
warnings };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Validate the complete catalog without pretending every registered module is selected. */
|
|
66
|
+
export function validateRegistry(source) {
|
|
67
|
+
const registry = manifest(source);
|
|
68
|
+
const entries = [
|
|
69
|
+
...registry.adapters.map(id => ({kind:'adapter',id,data:loadAdapter(source,id)})),
|
|
70
|
+
...(registry.capabilities ?? []).map(id => ({kind:'capability',id,data:loadCapability(source,id)})),
|
|
71
|
+
];
|
|
72
|
+
const declared = new Set(entries.map(m=>moduleRef(m.kind,m.id)));
|
|
73
|
+
const graph = new Map();
|
|
74
|
+
for (const {kind,id,data} of entries) {
|
|
75
|
+
const owner=moduleRef(kind,id);
|
|
76
|
+
for (const field of ['requires','recommends','conflicts']) {
|
|
77
|
+
for (const ref of data[field] ?? []) {
|
|
78
|
+
if (!declared.has(ref)) throw new HarnessError(`Unknown ${field} reference ${ref} in ${owner}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const id of data.compatible_adapters ?? []) {
|
|
82
|
+
if (!registry.adapters.includes(id)) throw new HarnessError(`Unknown compatible adapter ${id} in ${owner}`);
|
|
83
|
+
}
|
|
84
|
+
graph.set(owner,data.requires ?? []);
|
|
85
|
+
}
|
|
86
|
+
const states=new Map();
|
|
87
|
+
function visit(ref) {
|
|
88
|
+
if (states.get(ref)==='visiting') throw new HarnessError(`Module dependency cycle at ${ref}`);
|
|
89
|
+
if (states.get(ref)==='done') return;
|
|
90
|
+
states.set(ref,'visiting');
|
|
91
|
+
for (const dep of graph.get(ref) ?? []) visit(dep);
|
|
92
|
+
states.set(ref,'done');
|
|
93
|
+
}
|
|
94
|
+
for (const ref of graph.keys()) visit(ref);
|
|
95
|
+
return {status:'ok',modules:entries.length};
|
|
96
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readJson, safe, HarnessError, SLUG } from './files.js';
|
|
2
|
+
const requireThat = (condition, message) => { if (!condition) throw new HarnessError(message); };
|
|
3
|
+
const text = v => typeof v === 'string' && v.trim().length > 0;
|
|
4
|
+
const exact = (v, keys) => v && typeof v === 'object' && !Array.isArray(v)
|
|
5
|
+
&& Object.keys(v).sort().join(',') === [...keys].sort().join(',');
|
|
6
|
+
export function checksFrom(source, relative) {
|
|
7
|
+
const doc = readJson(safe(source, relative));
|
|
8
|
+
requireThat(exact(doc, ['schema_version', 'checks']) && doc.schema_version === 1 && Array.isArray(doc.checks)
|
|
9
|
+
&& doc.checks.length > 0, `Invalid verification: ${relative}`);
|
|
10
|
+
const ids = new Set();
|
|
11
|
+
for (const check of doc.checks) {
|
|
12
|
+
requireThat(!ids.has(check?.id), `Duplicate checks: ${relative}`);
|
|
13
|
+
requireThat(exact(check, ['id', 'title', 'command', 'when_file', 'evidence', 'execution'])
|
|
14
|
+
&& typeof check.id === 'string' && SLUG.test(check.id) && !ids.has(check.id)
|
|
15
|
+
&& text(check.title) && Array.isArray(check.command) && check.command.every(text)
|
|
16
|
+
&& Array.isArray(check.evidence) && check.evidence.length > 0 && check.evidence.every(text)
|
|
17
|
+
&& ((check.execution === 'advisory' && check.command.length > 0)
|
|
18
|
+
|| (check.execution === 'manual' && check.command.length === 0)), `Invalid check: ${relative}`);
|
|
19
|
+
if (check.when_file !== null) {
|
|
20
|
+
requireThat(typeof check.when_file === 'string', `Invalid when_file: ${relative}`);
|
|
21
|
+
safe(source, check.when_file);
|
|
22
|
+
}
|
|
23
|
+
ids.add(check.id);
|
|
24
|
+
}
|
|
25
|
+
return doc.checks;
|
|
26
|
+
}
|
|
27
|
+
export function casesFrom(source, relative) {
|
|
28
|
+
const doc = readJson(safe(source, relative));
|
|
29
|
+
requireThat(exact(doc, ['schema_version', 'cases']) && doc.schema_version === 1 && Array.isArray(doc.cases)
|
|
30
|
+
&& doc.cases.length > 0, `Invalid evaluation: ${relative}`);
|
|
31
|
+
const ids = new Set();
|
|
32
|
+
for (const c of doc.cases) {
|
|
33
|
+
requireThat(exact(c, ['id', 'prompt', 'criteria', 'critical'])
|
|
34
|
+
&& typeof c.id === 'string' && SLUG.test(c.id) && !ids.has(c.id)
|
|
35
|
+
&& text(c.prompt) && Array.isArray(c.criteria) && c.criteria.length > 0 && c.criteria.every(text)
|
|
36
|
+
&& typeof c.critical === 'boolean', `Invalid eval case: ${relative}`);
|
|
37
|
+
ids.add(c.id);
|
|
38
|
+
}
|
|
39
|
+
return doc.cases;
|
|
40
|
+
}
|
package/cli/files.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
// Resolve from the installed package, never the consumer's cwd or npm's bin symlink.
|
|
7
|
+
export const SOURCE = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
8
|
+
export const START = '<!-- ai-harness:start -->';
|
|
9
|
+
export const END = '<!-- ai-harness:end -->';
|
|
10
|
+
export const LOCK = '.harness/lock.json';
|
|
11
|
+
export const CONFIG = '.harness/project.json';
|
|
12
|
+
export const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
13
|
+
export const SLUG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
14
|
+
export const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
|
|
16
|
+
export class HarnessError extends Error {}
|
|
17
|
+
|
|
18
|
+
export function encode(value) {
|
|
19
|
+
// Matches v0.1 Python json.dumps(indent=2, sort_keys=True, ensure_ascii=False).
|
|
20
|
+
const sorted = item => Array.isArray(item) ? item.map(sorted) : isObject(item)
|
|
21
|
+
? Object.fromEntries(Object.keys(item).sort().map(k => [k, sorted(item[k])])) : item;
|
|
22
|
+
return Buffer.from(JSON.stringify(sorted(value), null, 2) + '\n');
|
|
23
|
+
}
|
|
24
|
+
export const digest = data => createHash('sha256').update(data).digest('hex');
|
|
25
|
+
|
|
26
|
+
export function stat(filename) {
|
|
27
|
+
try { return fs.lstatSync(filename); }
|
|
28
|
+
catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function safe(root, name) {
|
|
32
|
+
if (typeof name !== 'string' || !name || !/^[a-zA-Z0-9._/-]+$/.test(name)
|
|
33
|
+
|| path.posix.isAbsolute(name) || path.posix.normalize(name) !== name
|
|
34
|
+
|| name.split('/').some(p => !p || p === '.' || p === '..' || p.endsWith('.')
|
|
35
|
+
|| /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(p))) {
|
|
36
|
+
throw new HarnessError(`Unsafe relative path: ${name}`);
|
|
37
|
+
}
|
|
38
|
+
let current = root;
|
|
39
|
+
const parts = name.split('/');
|
|
40
|
+
for (const [index, part] of parts.entries()) {
|
|
41
|
+
current = path.join(current, part);
|
|
42
|
+
const info = stat(current);
|
|
43
|
+
if (!info) continue;
|
|
44
|
+
if (info.isSymbolicLink()) throw new HarnessError(`Symlink is not supported: ${current}`);
|
|
45
|
+
if (index < parts.length - 1 ? !info.isDirectory() : !info.isFile()) {
|
|
46
|
+
throw new HarnessError(`Unexpected filesystem entry: ${current}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return current;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function targetRoot(value) {
|
|
53
|
+
const root = path.resolve(value);
|
|
54
|
+
for (let current = root; ; current = path.dirname(current)) {
|
|
55
|
+
if (stat(current)?.isSymbolicLink()) throw new HarnessError(`Target contains a symlink: ${current}`);
|
|
56
|
+
if (path.dirname(current) === current) break;
|
|
57
|
+
}
|
|
58
|
+
if (!stat(root)?.isDirectory()) throw new HarnessError('Target must be an existing directory');
|
|
59
|
+
return root;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function assertSeparate(root, source) {
|
|
63
|
+
const inside = (parent, child) => {
|
|
64
|
+
const rel = path.relative(parent, child);
|
|
65
|
+
return !rel || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
|
|
66
|
+
};
|
|
67
|
+
// npm local installs intentionally place the package in the consumer's node_modules.
|
|
68
|
+
const localPackage = path.relative(root, source).split(path.sep)[0] === 'node_modules';
|
|
69
|
+
if (inside(source, root) || (inside(root, source) && !localPackage)) {
|
|
70
|
+
throw new HarnessError('Source and target must not overlap, except a source installed in node_modules');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function readJson(filename) {
|
|
75
|
+
try { return JSON.parse(fs.readFileSync(filename, 'utf8')); }
|
|
76
|
+
catch (error) { throw new HarnessError(`Cannot read JSON document: ${filename}: ${error.message}`); }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function block(data) {
|
|
80
|
+
const a = data.indexOf(START), b = data.indexOf(END);
|
|
81
|
+
if (a === -1 && b === -1) return null;
|
|
82
|
+
if (a === -1 || b < a || data.indexOf(START, a + START.length) !== -1
|
|
83
|
+
|| data.indexOf(END, b + END.length) !== -1) {
|
|
84
|
+
throw new HarnessError('AGENTS.md has duplicate, reversed or incomplete harness markers');
|
|
85
|
+
}
|
|
86
|
+
return data.subarray(a, b + END.length);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function mergeAgents(existing, generated) {
|
|
90
|
+
const old = block(existing);
|
|
91
|
+
if (old === null) {
|
|
92
|
+
const separator = !existing.length ? '' : existing.at(-1) === 10 ? '\n' : '\n\n';
|
|
93
|
+
return Buffer.concat([existing, Buffer.from(separator), generated, Buffer.from('\n')]);
|
|
94
|
+
}
|
|
95
|
+
const offset = existing.indexOf(START);
|
|
96
|
+
return Buffer.concat([existing.subarray(0, offset), generated, existing.subarray(offset + old.length)]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function payload(root, name) {
|
|
100
|
+
const filename = safe(root, name);
|
|
101
|
+
if (!stat(filename)) return null;
|
|
102
|
+
const data = fs.readFileSync(filename);
|
|
103
|
+
return name === 'AGENTS.md' ? block(data) : data;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function manifest(source) {
|
|
107
|
+
const m = readJson(safe(source, 'harness.yaml'));
|
|
108
|
+
if (!isObject(m) || m.schema_version !== 1 || !VERSION.test(m.version)) {
|
|
109
|
+
throw new HarnessError('Invalid harness manifest schema/version');
|
|
110
|
+
}
|
|
111
|
+
validateAdapters(m.adapters);
|
|
112
|
+
validateCapabilities(m.capabilities ?? []);
|
|
113
|
+
if (!m.adapters.length) throw new HarnessError('Empty adapter registry');
|
|
114
|
+
return m;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function validateAdapters(names) {
|
|
118
|
+
if (!Array.isArray(names) || names.some(a => typeof a !== 'string' || !SLUG.test(a))
|
|
119
|
+
|| new Set(names).size !== names.length) throw new HarnessError('Invalid or duplicate adapters');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function validateCapabilities(names) {
|
|
123
|
+
if (!Array.isArray(names) || names.some(a => typeof a !== 'string' || !SLUG.test(a))
|
|
124
|
+
|| new Set(names).size !== names.length) throw new HarnessError('Invalid or duplicate capabilities');
|
|
125
|
+
}
|
package/cli/harness.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { loadAdapter } from './adapter-schema.js';
|
|
5
|
+
import { loadCapability } from './capabilities.js';
|
|
6
|
+
import { resolveModules } from './composition.js';
|
|
7
|
+
import { safe, readJson, manifest, encode, digest, stat, block, mergeAgents, payload, validateAdapters, validateCapabilities, assertSeparate, HarnessError, LOCK, CONFIG, VERSION, SLUG, isObject } from './files.js';
|
|
8
|
+
export * from './files.js';
|
|
9
|
+
|
|
10
|
+
function projectConfig(root, adapters, capabilities, required = false) {
|
|
11
|
+
const filename = safe(root, CONFIG);
|
|
12
|
+
if (required && !stat(filename)) throw new HarnessError(`Missing ${CONFIG}; restore it from Git`);
|
|
13
|
+
const config = stat(filename) ? readJson(filename) : { schema_version: 1, adapters: adapters ?? [], capabilities: capabilities ?? [] };
|
|
14
|
+
const keys = isObject(config) ? Object.keys(config).sort().join(',') : '';
|
|
15
|
+
if (!isObject(config) || !['adapters,schema_version', 'adapters,capabilities,schema_version'].includes(keys)
|
|
16
|
+
|| config.schema_version !== 1) throw new HarnessError('Invalid project.json schema');
|
|
17
|
+
validateAdapters(config.adapters);
|
|
18
|
+
validateCapabilities(config.capabilities ?? []);
|
|
19
|
+
if (adapters !== undefined && stat(filename)
|
|
20
|
+
&& JSON.stringify([...adapters].sort()) !== JSON.stringify([...config.adapters].sort())) {
|
|
21
|
+
throw new HarnessError('Edit .harness/project.json to select adapters, then run update');
|
|
22
|
+
}
|
|
23
|
+
if (capabilities !== undefined && stat(filename)
|
|
24
|
+
&& JSON.stringify([...capabilities].sort()) !== JSON.stringify([...(config.capabilities ?? [])].sort())) {
|
|
25
|
+
throw new HarnessError('Edit .harness/project.json to select capabilities, then run update');
|
|
26
|
+
}
|
|
27
|
+
return config;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function collect(source, folder) {
|
|
31
|
+
const files = {};
|
|
32
|
+
const visit = name => {
|
|
33
|
+
const filename = path.join(source, name);
|
|
34
|
+
const info = stat(filename);
|
|
35
|
+
if (!info || info.isSymbolicLink()) throw new HarnessError(`Missing/unsafe source: ${name}`);
|
|
36
|
+
if (info.isDirectory()) {
|
|
37
|
+
for (const child of fs.readdirSync(filename).sort()) visit(`${name}/${child}`);
|
|
38
|
+
} else if (info.isFile()) files[name] = fs.readFileSync(safe(source, name));
|
|
39
|
+
else throw new HarnessError(`Unsupported source entry: ${name}`);
|
|
40
|
+
};
|
|
41
|
+
visit(folder);
|
|
42
|
+
if (!Object.keys(files).length) throw new HarnessError(`Empty source directory: ${folder}`);
|
|
43
|
+
return files;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function bundle(source, config) {
|
|
47
|
+
const selection = resolveModules(source, config);
|
|
48
|
+
const m = manifest(source), adapters = selection.adapters, capabilities = selection.capabilities;
|
|
49
|
+
const unknown = adapters.filter(a => !m.adapters.includes(a));
|
|
50
|
+
if (unknown.length) throw new HarnessError(`Unknown adapters: ${unknown.join(', ')}`);
|
|
51
|
+
const unknownCapabilities = capabilities.filter(c => !(m.capabilities ?? []).includes(c));
|
|
52
|
+
if (unknownCapabilities.length) throw new HarnessError(`Unknown capabilities: ${unknownCapabilities.join(', ')}`);
|
|
53
|
+
const files = {};
|
|
54
|
+
for (const folder of ['core', 'integrations', 'templates', ...adapters.map(a => `adapters/${a}`), ...capabilities.map(c => `capabilities/${c}`)]) {
|
|
55
|
+
for (const [name, data] of Object.entries(collect(source, folder))) {
|
|
56
|
+
files[`.harness/${name}`] = data;
|
|
57
|
+
if (name.startsWith('core/skills/')) files[`.agents/skills/${name.slice('core/skills/'.length)}`] = data;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const a of adapters) {
|
|
61
|
+
const info = loadAdapter(source, a);
|
|
62
|
+
for (const name of info.skills) {
|
|
63
|
+
const dest = `.agents/skills/${name}/SKILL.md`;
|
|
64
|
+
if (Object.hasOwn(files, dest)) throw new HarnessError(`Skill collision: ${name}`);
|
|
65
|
+
files[dest] = fs.readFileSync(safe(source, `adapters/${a}/skills/${name}/SKILL.md`));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const c of capabilities) {
|
|
69
|
+
const info = loadCapability(source, c);
|
|
70
|
+
for (const name of info.skills) {
|
|
71
|
+
const dest = `.agents/skills/${name}/SKILL.md`;
|
|
72
|
+
if (Object.hasOwn(files, dest)) throw new HarnessError(`Skill collision: ${name}`);
|
|
73
|
+
files[dest] = fs.readFileSync(safe(source, `capabilities/${c}/skills/${name}/SKILL.md`));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const capabilityLinks = capabilities.map(c => `- For ${c} tasks, read \`.harness/capabilities/${c}/workflows/\` and discover relevant \`.agents/skills/harness-${c}-*/SKILL.md\` by description.`).join('\n')
|
|
77
|
+
|| '- No optional capability selected.';
|
|
78
|
+
const links = adapters.map(a => `- Read \`.harness/adapters/${a}/rules.md\` and \`.harness/adapters/${a}/adapter.yaml\`; load relevant skills from \`.agents/skills/harness-${a}-*\`.`).join('\n') || '- No technology adapter selected.';
|
|
79
|
+
const template = fs.readFileSync(safe(source, 'templates/AGENTS.md'), 'utf8');
|
|
80
|
+
const generated = Buffer.from(template.replaceAll('{{version}}', m.version).replaceAll('{{adapters}}', links).replaceAll('{{capabilities}}', capabilityLinks).trim());
|
|
81
|
+
if (!block(generated)?.equals(generated)) throw new HarnessError('AGENTS template must be one managed block');
|
|
82
|
+
files['AGENTS.md'] = generated;
|
|
83
|
+
const hashes = Object.fromEntries(Object.keys(files).sort().map(k => [k, digest(files[k])]));
|
|
84
|
+
return { files, warnings: selection.warnings, lock: { schema_version: 1, version: m.version, adapters, capabilities, files: hashes, bundle_sha256: digest(encode(hashes)) } };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function managedName(name) {
|
|
88
|
+
return name === 'AGENTS.md' || ['.harness/core/', '.harness/adapters/', '.harness/capabilities/', '.harness/integrations/', '.harness/templates/', '.agents/skills/harness-'].some(prefix => name.startsWith(prefix));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function loadLock(root) {
|
|
92
|
+
const lock = readJson(safe(root, LOCK));
|
|
93
|
+
if (!isObject(lock) || !['adapters,bundle_sha256,files,schema_version,version',
|
|
94
|
+
'adapters,bundle_sha256,capabilities,files,schema_version,version'].includes(Object.keys(lock).sort().join(','))
|
|
95
|
+
|| lock.schema_version !== 1 || !VERSION.test(lock.version) || !isObject(lock.files)
|
|
96
|
+
|| !Object.hasOwn(lock.files, 'AGENTS.md')) throw new HarnessError('Invalid lock schema/content');
|
|
97
|
+
validateAdapters(lock.adapters);
|
|
98
|
+
validateCapabilities(lock.capabilities ?? []);
|
|
99
|
+
for (const [name, sha] of Object.entries(lock.files)) {
|
|
100
|
+
if (!managedName(name) || typeof sha !== 'string' || !/^[0-9a-f]{64}$/.test(sha)) throw new HarnessError(`Invalid lock entry: ${name}`);
|
|
101
|
+
safe(root, name);
|
|
102
|
+
}
|
|
103
|
+
if (lock.bundle_sha256 !== digest(encode(lock.files))) throw new HarnessError('Lock digest mismatch');
|
|
104
|
+
return lock;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function drift(root, lock) {
|
|
108
|
+
return Object.entries(lock.files).filter(([name, sha]) => {
|
|
109
|
+
const data = payload(root, name);
|
|
110
|
+
return data === null || digest(data) !== sha;
|
|
111
|
+
}).map(([name]) => name);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function guard(root, operation) {
|
|
115
|
+
const filename = safe(root, '.harness-install.lock');
|
|
116
|
+
let fd;
|
|
117
|
+
try { fd = fs.openSync(filename, 'wx', 0o600); }
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error.code === 'EEXIST') throw new HarnessError('Another installation is active; inspect .harness-install.lock before removing a stale lock');
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
try { fs.writeSync(fd, String(process.pid)); return operation(); }
|
|
123
|
+
finally { fs.closeSync(fd); fs.unlinkSync(filename); }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function atomicWrite(filename, data) {
|
|
127
|
+
fs.mkdirSync(path.dirname(filename), { recursive: true });
|
|
128
|
+
const tmp = path.join(path.dirname(filename), `.harness-tmp-${randomUUID()}`);
|
|
129
|
+
try {
|
|
130
|
+
fs.writeFileSync(tmp, data, { flag: 'wx', mode: 0o600 });
|
|
131
|
+
fs.chmodSync(tmp, stat(filename) ? fs.statSync(filename).mode & 0o777 : 0o644);
|
|
132
|
+
fs.renameSync(tmp, filename);
|
|
133
|
+
} finally { fs.rmSync(tmp, { force: true }); }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function transaction(root, changes, writer = atomicWrite) {
|
|
137
|
+
const originals = {}, applied = [];
|
|
138
|
+
for (const name of Object.keys(changes)) {
|
|
139
|
+
const filename = safe(root, name);
|
|
140
|
+
originals[name] = stat(filename) ? fs.readFileSync(filename) : null;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
for (const [name, data] of Object.entries(changes)) {
|
|
144
|
+
const filename = safe(root, name);
|
|
145
|
+
if (data === null) fs.rmSync(filename, { force: true });
|
|
146
|
+
else writer(filename, data);
|
|
147
|
+
applied.push(name);
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
for (const name of applied.reverse()) {
|
|
151
|
+
const filename = safe(root, name);
|
|
152
|
+
if (originals[name] === null) fs.rmSync(filename, { force: true });
|
|
153
|
+
else atomicWrite(filename, originals[name]);
|
|
154
|
+
}
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function install(root, source, { command = 'init', adapters, capabilities, dryRun = false, allowMajor = false, writer } = {}) {
|
|
160
|
+
assertSeparate(root, source);
|
|
161
|
+
return guard(root, () => {
|
|
162
|
+
const hasLock = Boolean(stat(safe(root, LOCK)));
|
|
163
|
+
if (command === 'update' && !hasLock) throw new HarnessError('Not installed; run init first');
|
|
164
|
+
const old = hasLock ? loadLock(root) : null;
|
|
165
|
+
const config = projectConfig(root, adapters, capabilities, hasLock);
|
|
166
|
+
const { files, lock, warnings } = bundle(source, config);
|
|
167
|
+
if (old) {
|
|
168
|
+
const conflicts = drift(root, old);
|
|
169
|
+
if (conflicts.length) throw new HarnessError(`Managed files changed; reconcile in Git before updating: ${conflicts.join(', ')}`);
|
|
170
|
+
const before = old.version.split('.').map(BigInt), after = lock.version.split('.').map(BigInt);
|
|
171
|
+
const diff = after.findIndex((v, i) => v !== before[i]);
|
|
172
|
+
if (diff !== -1 && after[diff] < before[diff]) throw new HarnessError('Downgrades are refused; revert the installation commit in Git');
|
|
173
|
+
if (after[0] !== before[0] && !allowMajor) throw new HarnessError('Major upgrade requires --allow-major and migration review');
|
|
174
|
+
if (command === 'init' && !encode(old).equals(encode(lock))) throw new HarnessError('Already installed; use update');
|
|
175
|
+
}
|
|
176
|
+
const changes = {};
|
|
177
|
+
for (const [name, data] of Object.entries(files)) {
|
|
178
|
+
const present = payload(root, name), owned = old && Object.hasOwn(old.files, name);
|
|
179
|
+
if (present !== null && !owned && !present.equals(data)) throw new HarnessError(`Unmanaged file collision: ${name}`);
|
|
180
|
+
if (present === null || !present.equals(data)) {
|
|
181
|
+
const filename = safe(root, name);
|
|
182
|
+
const existing = stat(filename) ? fs.readFileSync(filename) : Buffer.alloc(0);
|
|
183
|
+
changes[name] = name === 'AGENTS.md' ? mergeAgents(existing, data) : data;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (old) for (const name of Object.keys(old.files)) if (!Object.hasOwn(files, name)) changes[name] = null;
|
|
187
|
+
const seeds = {
|
|
188
|
+
[CONFIG]: encode(config),
|
|
189
|
+
'openspec/config.yaml': fs.readFileSync(safe(source, 'templates/openspec/config.yaml')),
|
|
190
|
+
'.harness/local.md': Buffer.from('# Project overrides\n\nRecord project facts and reviewed exceptions here. Never weaken security boundaries.\n'),
|
|
191
|
+
};
|
|
192
|
+
for (const [name, data] of Object.entries(seeds)) if (!stat(safe(root, name))) changes[name] = data;
|
|
193
|
+
const newLock = encode(lock);
|
|
194
|
+
if (!hasLock || !fs.readFileSync(safe(root, LOCK)).equals(newLock)) changes[LOCK] = newLock;
|
|
195
|
+
const plan = Object.entries(changes).map(([name, data]) => ({ path: name, action: data === null ? 'delete' : 'write' }));
|
|
196
|
+
if (!dryRun) transaction(root, changes, writer);
|
|
197
|
+
return { status: dryRun ? 'planned' : 'ok', version: lock.version, changes: plan, warnings };
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function check(root) {
|
|
202
|
+
const lock = loadLock(root), config = projectConfig(root, undefined, undefined, true);
|
|
203
|
+
const problems = drift(root, lock);
|
|
204
|
+
if (JSON.stringify([...config.adapters].sort()) !== JSON.stringify([...lock.adapters].sort())) problems.push(`${CONFIG}: adapter selection changed; run update`);
|
|
205
|
+
if (JSON.stringify([...(config.capabilities ?? [])].sort()) !== JSON.stringify([...(lock.capabilities ?? [])].sort())) problems.push(`${CONFIG}: capability selection changed; run update`);
|
|
206
|
+
return { status: problems.length ? 'error' : 'ok', version: lock.version, problems };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function newChange(root, name) {
|
|
210
|
+
if (!SLUG.test(name) || name === 'archive') throw new HarnessError('Change ID must be a lowercase hyphenated slug, excluding archive');
|
|
211
|
+
return guard(root, () => {
|
|
212
|
+
if (check(root).status !== 'ok') throw new HarnessError('Fix installation drift before creating a change');
|
|
213
|
+
const folder = path.join(root, 'openspec', 'changes', name);
|
|
214
|
+
safe(root, `openspec/changes/${name}/proposal.md`);
|
|
215
|
+
if (stat(folder)) throw new HarnessError(`Change already exists: ${name}`);
|
|
216
|
+
const mapping = { 'proposal.md': 'proposal.md', 'design.md': 'design.md', 'tasks.md': 'tasks.md', 'spec.md': `specs/${name}/spec.md`, '.openspec.yaml': '.openspec.yaml' };
|
|
217
|
+
const changes = {};
|
|
218
|
+
for (const [src, dest] of Object.entries(mapping)) {
|
|
219
|
+
const text = fs.readFileSync(safe(root, `.harness/templates/openspec/${src}`), 'utf8');
|
|
220
|
+
changes[`openspec/changes/${name}/${dest}`] = Buffer.from(text.replaceAll('{{change_id}}', name));
|
|
221
|
+
}
|
|
222
|
+
transaction(root, changes);
|
|
223
|
+
return { status: 'ok', change: name, path: folder, note: 'Draft only; replace TODOs and validate with OpenSpec' };
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Product drafts are project-owned and never added to the managed bundle.
|
|
228
|
+
export function newDoc(root, name) {
|
|
229
|
+
if (!SLUG.test(name)) throw new HarnessError('Document type must be a lowercase hyphenated slug');
|
|
230
|
+
return guard(root, () => {
|
|
231
|
+
if (check(root).status !== 'ok') throw new HarnessError('Fix installation drift before creating a document');
|
|
232
|
+
const lock = loadLock(root);
|
|
233
|
+
if (!(lock.capabilities ?? []).includes('product')) throw new HarnessError('Product capability is not installed');
|
|
234
|
+
const sourceName = `.harness/capabilities/product/templates/${name}.md`;
|
|
235
|
+
if (!Object.hasOwn(lock.files, sourceName)) throw new HarnessError(`Unknown product document type: ${name}`);
|
|
236
|
+
const folder = ['idea', 'research'].includes(name) ? 'discovery' : 'product';
|
|
237
|
+
const outputName = `docs/${folder}/${name}.md`;
|
|
238
|
+
if (stat(safe(root, outputName))) throw new HarnessError(`Document already exists: ${outputName}`);
|
|
239
|
+
transaction(root, { [outputName]: fs.readFileSync(safe(root, sourceName)) });
|
|
240
|
+
return { status: 'ok', type: name, path: outputName,
|
|
241
|
+
note: 'Template only; populate from authorized evidence and obtain human approval.' };
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function doctor(root) {
|
|
246
|
+
const extensions = process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : [''];
|
|
247
|
+
const tools = {};
|
|
248
|
+
for (const name of ['git', 'node', 'npm', 'java', 'mvn', 'openspec']) {
|
|
249
|
+
tools[name] = (process.env.PATH || '').split(path.delimiter).filter(Boolean).some(dir => extensions.some(ext => {
|
|
250
|
+
try { const p = path.join(dir, name + ext); fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); }
|
|
251
|
+
catch { return false; }
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
return { ...check(root), tools, note: 'Tool presence only; no commands executed or compatibility claim made' };
|
|
255
|
+
}
|
package/cli/main.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { listAdapters, detect, verify, evaluate } from './adapters.js';
|
|
2
|
+
import { listCapabilities, skillCatalog, readSkill } from './capabilities.js';
|
|
3
|
+
import { resolveModules } from './composition.js';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { SOURCE, readJson, targetRoot, install, check, doctor, newChange, newDoc, loadLock } from './harness.js';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const HELP = `aih — install reusable AI development rules into a project
|
|
9
|
+
|
|
10
|
+
Usage: aih <command> [options]
|
|
11
|
+
list List available adapters and capabilities
|
|
12
|
+
skills List installed skill names/descriptions without full content
|
|
13
|
+
skill <name> Load one installed skill on demand
|
|
14
|
+
detect Suggest adapters with evidence from target files
|
|
15
|
+
resolve Validate a module selection and show compatibility warnings
|
|
16
|
+
verify Show installed advisory checks (does not execute)
|
|
17
|
+
eval Emit evaluation template, or score --report <file>
|
|
18
|
+
init Install rules and selected adapters
|
|
19
|
+
update Apply the installed CLI bundle to the project (no download)
|
|
20
|
+
check Check Harness file integrity, not application tests
|
|
21
|
+
doctor Check installation and tool presence
|
|
22
|
+
new-change <id> Create an OpenSpec draft using installed templates
|
|
23
|
+
new-doc <type> Scaffold a Product/Discovery document draft (requires product capability)
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--target <directory> Project directory (default: current directory)
|
|
27
|
+
--detect init only: opt in to detected stack selection
|
|
28
|
+
--scope <type> verify/eval: adapters (default), capabilities, core, all
|
|
29
|
+
--report <file> eval only: score a reviewer-completed JSON report
|
|
30
|
+
--adapters <a,b> init/resolve: java-spring,react-nextjs,react-native,ai-python,kubernetes,astro
|
|
31
|
+
--capabilities <a,b> init/resolve: uiux,product,devops,monetization
|
|
32
|
+
--source <directory> init/update/list/detect/resolve: trusted alternate bundle directory
|
|
33
|
+
--dry-run init/update: preview without persistent changes
|
|
34
|
+
--allow-major init/update: acknowledge a major version migration
|
|
35
|
+
-h, --help Show help
|
|
36
|
+
-v, --version Show installed CLI version
|
|
37
|
+
|
|
38
|
+
Start: init → fill .harness/local.md → ask your AI host to read AGENTS.md.
|
|
39
|
+
The host runs application tests; verify only prints a checklist.
|
|
40
|
+
Update a global CLI with npm install -g @manhd_d/ai-harness@latest,
|
|
41
|
+
then run aih update --dry-run, aih update, and aih check in the project.
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
aih init --adapters java-spring --capabilities uiux
|
|
45
|
+
aih init --adapters astro --capabilities product,uiux
|
|
46
|
+
aih init --adapters astro --capabilities monetization
|
|
47
|
+
aih resolve --adapters java-spring,kubernetes --capabilities product,uiux,devops
|
|
48
|
+
aih skills
|
|
49
|
+
aih new-doc brd
|
|
50
|
+
aih new-doc idea
|
|
51
|
+
aih new-doc research
|
|
52
|
+
aih new-doc prd-lite
|
|
53
|
+
aih new-doc srs
|
|
54
|
+
aih new-doc use-cases
|
|
55
|
+
aih init --adapters java-spring,kubernetes --capabilities product,devops
|
|
56
|
+
aih skill harness-product-brd-generation
|
|
57
|
+
aih skill harness-uiux-figma-design
|
|
58
|
+
aih update --dry-run
|
|
59
|
+
aih new-change add-order
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
export function main(argv = process.argv.slice(2)) {
|
|
63
|
+
let args;
|
|
64
|
+
try {
|
|
65
|
+
args = parseArgs({ args: argv, allowPositionals: true, strict: true, tokens: true, options: {
|
|
66
|
+
target: { type: 'string' }, adapters: { type: 'string' }, capabilities: { type: 'string' }, source: { type: 'string' },
|
|
67
|
+
detect: { type: 'boolean' }, report: { type: 'string' }, scope: { type: 'string' },
|
|
68
|
+
'dry-run': { type: 'boolean' }, 'allow-major': { type: 'boolean' },
|
|
69
|
+
help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
|
|
70
|
+
} });
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
for (const token of args.tokens.filter(t => t.kind === 'option')) {
|
|
73
|
+
if (seen.has(token.name)) throw new Error(`Duplicate option: --${token.name}`);
|
|
74
|
+
seen.add(token.name);
|
|
75
|
+
}
|
|
76
|
+
if (args.values.help || !argv.length) { console.log(HELP); return 0; }
|
|
77
|
+
if (args.values.version) { console.log(readJson(path.join(SOURCE, 'package.json')).version); return 0; }
|
|
78
|
+
const [command, name, ...extra] = args.positionals;
|
|
79
|
+
if (!['init', 'update', 'check', 'doctor', 'new-change', 'new-doc', 'list', 'detect', 'resolve', 'verify', 'eval', 'skills', 'skill'].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
80
|
+
if (extra.length || (!['new-change', 'new-doc', 'skill'].includes(command) && name !== undefined) || (['new-change', 'new-doc', 'skill'].includes(command) && name === undefined)) throw new Error('Unexpected or missing positional argument; run aih --help');
|
|
81
|
+
const allowed = new Set(['target', ...(['init', 'update'].includes(command) ? ['source', 'dry-run', 'allow-major'] : []), ...(['init', 'resolve'].includes(command) ? ['adapters', 'capabilities'] : []), ...(command === 'init' ? ['detect'] : []), ...(['list', 'detect', 'resolve'].includes(command) ? ['source'] : []), ...(command === 'eval' ? ['report'] : []), ...(['verify', 'eval'].includes(command) ? ['scope'] : [])]);
|
|
82
|
+
for (const key of Object.keys(args.values)) if (!allowed.has(key)) throw new Error(`--${key} is not supported for ${command}`);
|
|
83
|
+
if (args.values.detect && args.values.adapters !== undefined) throw new Error('Choose either --detect or --adapters');
|
|
84
|
+
for (const key of ['target', 'source', 'adapters', 'capabilities', 'report']) if (args.values[key] !== undefined && !args.values[key].trim()) throw new Error(`--${key} cannot be empty`);
|
|
85
|
+
} catch (error) { console.error(JSON.stringify({ status: 'error', message: error.message })); return 2; }
|
|
86
|
+
try {
|
|
87
|
+
const { values, positionals: [command, name] } = args;
|
|
88
|
+
const root = targetRoot(values.target ?? process.cwd());
|
|
89
|
+
let result;
|
|
90
|
+
if (command === 'init' || command === 'update') {
|
|
91
|
+
const source = targetRoot(values.source ?? SOURCE);
|
|
92
|
+
const selection = values.detect ? detect(root, source).adapters : undefined;
|
|
93
|
+
if (values.detect && !selection.length) throw new Error('No stack detected; choose --adapters explicitly or omit --detect for core only');
|
|
94
|
+
result = install(root, source, {
|
|
95
|
+
command, adapters: selection ?? (values.adapters === undefined ? undefined : values.adapters.split(',').map(v => v.trim())),
|
|
96
|
+
capabilities: values.capabilities === undefined ? undefined : values.capabilities.split(',').map(v => v.trim()),
|
|
97
|
+
dryRun: values['dry-run'], allowMajor: values['allow-major'],
|
|
98
|
+
});
|
|
99
|
+
} else if (command === 'list') { const source = targetRoot(values.source ?? SOURCE); result = { ...listAdapters(source), capabilities: listCapabilities(source) }; }
|
|
100
|
+
else if (command === 'skills' || command === 'skill') {
|
|
101
|
+
const state = check(root);
|
|
102
|
+
if (state.status !== 'ok') throw new Error(`Fix installation drift before loading skills: ${state.problems.join(', ')}`);
|
|
103
|
+
const lock = loadLock(root);
|
|
104
|
+
result = command === 'skills' ? skillCatalog(root, lock) : readSkill(root, lock, name);
|
|
105
|
+
}
|
|
106
|
+
else if (command === 'resolve') result = resolveModules(targetRoot(values.source ?? SOURCE), {
|
|
107
|
+
adapters: values.adapters === undefined ? [] : values.adapters.split(',').map(x => x.trim()),
|
|
108
|
+
capabilities: values.capabilities === undefined ? [] : values.capabilities.split(',').map(x => x.trim()),
|
|
109
|
+
});
|
|
110
|
+
else if (command === 'detect') result = detect(root, targetRoot(values.source ?? SOURCE));
|
|
111
|
+
else if (command === 'verify') result = verify(root, values.scope ?? 'adapters');
|
|
112
|
+
else if (command === 'eval') result = evaluate(root, values.report, values.scope ?? 'adapters');
|
|
113
|
+
else if (command === 'new-change') result = newChange(root, name);
|
|
114
|
+
else if (command === 'new-doc') result = newDoc(root, name);
|
|
115
|
+
else result = command === 'doctor' ? doctor(root) : check(root);
|
|
116
|
+
console.log(JSON.stringify(result, null, 2));
|
|
117
|
+
return ['error', 'fail'].includes(result.status) ? 1 : 0;
|
|
118
|
+
} catch (error) { console.error(JSON.stringify({ status: 'error', message: error.message })); return 1; }
|
|
119
|
+
}
|