@manhd_d/ai-harness 0.6.2 → 0.8.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 (138) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/CONTRIBUTING.md +4 -0
  3. package/README.md +20 -3
  4. package/adapters/java-spring/adapter.yaml +3 -2
  5. package/adapters/java-spring/checks/verification.yaml +12 -0
  6. package/adapters/java-spring/evals/cases.json +10 -0
  7. package/adapters/java-spring/rules/conventions.md +39 -0
  8. package/adapters/java-spring/rules.md +2 -1
  9. package/adapters/kubernetes/adapter.yaml +1 -1
  10. package/adapters/kubernetes/rules.md +3 -0
  11. package/adapters/react-native/adapter.yaml +3 -2
  12. package/adapters/react-native/checks/verification.yaml +12 -0
  13. package/adapters/react-native/evals/cases.json +10 -0
  14. package/adapters/react-native/rules/conventions.md +34 -0
  15. package/adapters/react-native/rules.md +2 -1
  16. package/adapters/react-nextjs/adapter.yaml +3 -2
  17. package/adapters/react-nextjs/checks/verification.yaml +12 -0
  18. package/adapters/react-nextjs/evals/cases.json +10 -0
  19. package/adapters/react-nextjs/rules/conventions.md +37 -0
  20. package/adapters/react-nextjs/rules.md +2 -1
  21. package/capabilities/architecture/README.md +11 -0
  22. package/capabilities/architecture/agents/architecture-reviewer.md +16 -0
  23. package/capabilities/architecture/agents/solution-architect.md +16 -0
  24. package/capabilities/architecture/capability.json +35 -0
  25. package/capabilities/architecture/checks/verification.json +29 -0
  26. package/capabilities/architecture/evals/cases.json +35 -0
  27. package/capabilities/architecture/skills/harness-architecture-change-impact/SKILL.md +43 -0
  28. package/capabilities/architecture/skills/harness-architecture-decision-records/SKILL.md +43 -0
  29. package/capabilities/architecture/skills/harness-architecture-project-context/SKILL.md +43 -0
  30. package/capabilities/architecture/skills/harness-architecture-quality-attributes/SKILL.md +43 -0
  31. package/capabilities/architecture/skills/harness-architecture-review/SKILL.md +43 -0
  32. package/capabilities/architecture/skills/harness-architecture-solution-design/SKILL.md +44 -0
  33. package/capabilities/architecture/templates/adr.md +29 -0
  34. package/capabilities/architecture/templates/nfr.md +22 -0
  35. package/capabilities/architecture/templates/overview.md +24 -0
  36. package/capabilities/architecture/templates/project-map.md +26 -0
  37. package/capabilities/architecture/templates/review.md +23 -0
  38. package/capabilities/architecture/templates/solution-design.md +29 -0
  39. package/capabilities/architecture/workflows/change-review.md +12 -0
  40. package/capabilities/architecture/workflows/discovery-to-design.md +16 -0
  41. package/capabilities/delivery/README.md +11 -0
  42. package/capabilities/delivery/agents/project-manager.md +16 -0
  43. package/capabilities/delivery/capability.json +29 -0
  44. package/capabilities/delivery/checks/verification.json +29 -0
  45. package/capabilities/delivery/evals/cases.json +25 -0
  46. package/capabilities/delivery/skills/harness-delivery-planning/SKILL.md +43 -0
  47. package/capabilities/delivery/skills/harness-delivery-risk-management/SKILL.md +42 -0
  48. package/capabilities/delivery/skills/harness-delivery-scope-change/SKILL.md +43 -0
  49. package/capabilities/delivery/templates/change-assessment.md +23 -0
  50. package/capabilities/delivery/templates/delivery-plan.md +25 -0
  51. package/capabilities/delivery/templates/risk-register.md +19 -0
  52. package/capabilities/delivery/workflows/execution-change.md +9 -0
  53. package/capabilities/delivery/workflows/planning.md +12 -0
  54. package/capabilities/product/README.md +7 -0
  55. package/capabilities/product/agents/business-analyst.md +3 -0
  56. package/capabilities/product/agents/product-owner.md +5 -1
  57. package/capabilities/product/agents/project-manager.md +5 -0
  58. package/capabilities/product/capability.json +9 -4
  59. package/capabilities/product/checks/verification.json +12 -0
  60. package/capabilities/product/evals/cases.json +10 -0
  61. package/capabilities/product/skills/harness-product-acceptance-criteria/SKILL.md +17 -0
  62. package/capabilities/product/skills/harness-product-backlog-prioritization/SKILL.md +17 -0
  63. package/capabilities/product/skills/harness-product-brd-generation/SKILL.md +17 -0
  64. package/capabilities/product/skills/harness-product-business-analysis/SKILL.md +17 -0
  65. package/capabilities/product/skills/harness-product-document-review/SKILL.md +17 -0
  66. package/capabilities/product/skills/harness-product-idea-discovery/SKILL.md +17 -0
  67. package/capabilities/product/skills/harness-product-knowledge-analysis/SKILL.md +17 -0
  68. package/capabilities/product/skills/harness-product-mvp-definition/SKILL.md +43 -0
  69. package/capabilities/product/skills/harness-product-prd-generation/SKILL.md +17 -0
  70. package/capabilities/product/skills/harness-product-product-outcomes/SKILL.md +42 -0
  71. package/capabilities/product/skills/harness-product-product-research/SKILL.md +17 -0
  72. package/capabilities/product/skills/harness-product-srs-generation/SKILL.md +17 -0
  73. package/capabilities/product/skills/harness-product-traceability/SKILL.md +17 -0
  74. package/capabilities/product/skills/harness-product-use-case-generation/SKILL.md +17 -0
  75. package/capabilities/product/skills/harness-product-user-stories/SKILL.md +17 -0
  76. package/capabilities/product/templates/mvp.md +25 -0
  77. package/capabilities/product/templates/outcomes.md +23 -0
  78. package/capabilities/product/workflows/document-driven.md +4 -0
  79. package/capabilities/product/workflows/idea-driven.md +4 -0
  80. package/capabilities/product/workflows/product-to-solution.md +15 -0
  81. package/cli/agents.js +165 -0
  82. package/cli/environment.js +170 -0
  83. package/cli/harness.js +40 -10
  84. package/cli/main.js +47 -11
  85. package/cli/readiness.js +117 -0
  86. package/config/agents.default.json +80 -0
  87. package/core/agents/documentation.md +13 -0
  88. package/core/agents/implementer.md +7 -0
  89. package/core/agents/orchestrator.md +13 -0
  90. package/core/agents/planner.md +7 -0
  91. package/core/agents/reviewer.md +8 -0
  92. package/core/checks/verification.json +37 -0
  93. package/core/evals/cases.json +101 -0
  94. package/core/evals/composition.json +15 -0
  95. package/core/rules/foundations.md +1 -0
  96. package/core/rules/project-conventions.md +61 -0
  97. package/core/skills/harness-review/SKILL.md +2 -0
  98. package/core/skills/harness-spec/SKILL.md +6 -2
  99. package/core/workflows/agent-routing.md +28 -0
  100. package/core/workflows/bugfix.md +2 -0
  101. package/core/workflows/clarification.md +28 -0
  102. package/core/workflows/project-setup.md +37 -0
  103. package/core/workflows/solution-framing.md +33 -0
  104. package/core/workflows/spec-to-code.md +18 -3
  105. package/docs/adapters.md +6 -0
  106. package/docs/agent-configuration.md +99 -0
  107. package/docs/agent-handoffs.md +19 -0
  108. package/docs/architecture.md +33 -1
  109. package/docs/change-readiness.md +67 -0
  110. package/docs/cli.md +41 -3
  111. package/docs/documentation-workflows.md +7 -0
  112. package/docs/end-to-end-workflow.md +10 -1
  113. package/docs/getting-started.md +29 -2
  114. package/docs/harness-maintenance.md +80 -0
  115. package/docs/monetization.md +1 -1
  116. package/docs/project-configuration.md +86 -0
  117. package/docs/project-conventions.md +67 -0
  118. package/docs/research/solution-workflow-sources.md +30 -0
  119. package/docs/skill-matrix.md +28 -4
  120. package/docs/solution-workflow.md +67 -0
  121. package/docs/templates/harness-change-impact.md +30 -0
  122. package/docs/tool-setup/README.md +70 -0
  123. package/docs/tool-setup/claude-code.md +57 -0
  124. package/docs/tool-setup/codex.md +44 -0
  125. package/docs/tool-setup/copilot.md +42 -0
  126. package/docs/tool-setup/cursor.md +44 -0
  127. package/docs/tool-setup/gemini-cli.md +41 -0
  128. package/docs/versioning.md +37 -0
  129. package/evals/README.md +16 -0
  130. package/evals/real-task-comparison.md +49 -0
  131. package/harness.yaml +4 -2
  132. package/package.json +2 -1
  133. package/templates/AGENTS.md +13 -1
  134. package/templates/environment.json +6 -0
  135. package/templates/openspec/decisions.json +5 -0
  136. package/templates/openspec/design.md +6 -0
  137. package/templates/openspec/tasks.md +1 -0
  138. package/templates/openspec/verification.json +4 -0
@@ -24,3 +24,20 @@ description: Translate requirement and story IDs into consistent main, alternati
24
24
  - Never imply a draft flow is accepted without sign-off.
25
25
  - Avoid duplicating product scope in a competing document; reference established IDs.
26
26
  - No assumptions about system behavior presented as approved facts.
27
+
28
+ ## Template
29
+ Read `.harness/capabilities/product/templates/use-cases.md`; `aih new-doc use-cases` creates only a missing
30
+ draft. Reuse canonical existing documents and source pointers; never regenerate approved content by default.
31
+
32
+ ## Example
33
+ Synthetic illustration: UC-001 contains main lookup, no-result, permission denial and provider timeout paths linked to FR/AC.
34
+
35
+ ## Quality checklist
36
+ - Actor, precondition, result and alternate/error flows are explicit.
37
+ - Actual decisions/status and source revisions distinguish approved facts from proposals.
38
+ - Scope, relevant IDs, unresolved questions and verification limits are recorded.
39
+
40
+ ## Handoff
41
+ Use `.harness/capabilities/product/workflows/product-to-solution.md`; supply reviewed source/requirement
42
+ IDs, output paths and actual owner decisions to the next BA/PO/SA/PM role as applicable. Return gaps
43
+ to their owner; a single host may perform roles sequentially without claiming independent approval.
@@ -23,3 +23,20 @@ description: Turn approved requirements into atomic epics and user stories with
23
23
  ## Constraints
24
24
  - Do not fabricate persona needs or change scope via user-story rewriting.
25
25
  - A story marked Ready requires actual acceptance criteria and PO review.
26
+
27
+ ## Template
28
+ Read `.harness/capabilities/product/templates/backlog.md`; `aih new-doc backlog` creates only a missing
29
+ draft. Reuse canonical existing documents and source pointers; never regenerate approved content by default.
30
+
31
+ ## Example
32
+ Synthetic illustration: US-001: authorized employee finds a customer; links FR-001 and AC-US-001-01, with access-denial criteria.
33
+
34
+ ## Quality checklist
35
+ - Story has user value, dependency and observable acceptance.
36
+ - Actual decisions/status and source revisions distinguish approved facts from proposals.
37
+ - Scope, relevant IDs, unresolved questions and verification limits are recorded.
38
+
39
+ ## Handoff
40
+ Use `.harness/capabilities/product/workflows/product-to-solution.md`; supply reviewed source/requirement
41
+ IDs, output paths and actual owner decisions to the next BA/PO/SA/PM role as applicable. Return gaps
42
+ to their owner; a single host may perform roles sequentially without claiming independent approval.
@@ -0,0 +1,25 @@
1
+ # MVP scope
2
+
3
+ Status: draft / pending review
4
+ Owner: TODO
5
+ Sources and revisions: TODO
6
+ Scope / related requirement and decision IDs: TODO
7
+ Last verified against code: TODO (or not applicable with reason)
8
+
9
+ ## User and outcome
10
+ Target actor, problem, value-bearing end-to-end journey and validation evidence.
11
+ ## Scope cuts
12
+ | Capability | In / out | Value / dependency | Requirement / AC | Decision source |
13
+ | --- | --- | --- | --- | --- |
14
+ | TODO | TODO | TODO | TODO | TODO |
15
+ ## Minimum viable quality
16
+ Authorization, data integrity, failure recovery and relevant NFRs that cannot be deferred.
17
+ ## Acceptance and learning
18
+ Observable journey completion, success signals, measurement owner, known baseline/target or unknown.
19
+ ## Open decisions
20
+ Options/trade-offs, recommended cut with evidence and actual owner answer; unanswered choices remain pending.
21
+
22
+
23
+ ## Review and handoff
24
+ Record actual owner decisions, unresolved consequential questions, verification performed/skipped,
25
+ next role, source/output paths and affected OpenSpec change. Draft text is not an accepted decision.
@@ -0,0 +1,23 @@
1
+ # Product outcomes
2
+
3
+ Status: draft / pending review
4
+ Owner: TODO
5
+ Sources and revisions: TODO
6
+ Scope / related requirement and decision IDs: TODO
7
+ Last verified against code: TODO (or not applicable with reason)
8
+
9
+ ## Value hypothesis
10
+ User problem and expected benefit; link MVP/PRD instead of duplicating scope.
11
+ ## Measurement plan
12
+ | Outcome / metric | Definition / source | Baseline | Proposed target / authority | Owner / period |
13
+ | --- | --- | --- | --- | --- |
14
+ | TODO | TODO | unknown | pending | TODO |
15
+ ## Experiment and guardrails
16
+ Hypothesis, smallest experiment, cost/privacy limits, negative effects and decision checkpoint.
17
+ ## Evidence and next action
18
+ Separate observed results from forecasts; propose continue/pivot/stop without self-approving scope changes.
19
+
20
+
21
+ ## Review and handoff
22
+ Record actual owner decisions, unresolved consequential questions, verification performed/skipped,
23
+ next role, source/output paths and affected OpenSpec change. Draft text is not an accepted decision.
@@ -11,3 +11,7 @@ Use when a project already supplies authorized BA/PO materials (Confluence, BRD,
11
11
  7. **Source changes:** compare actual source revisions and propose a reviewed requirements/code impact diff rather than overwriting user-owned artifacts.
12
12
 
13
13
  Canonical enterprise source stays in the enterprise system; consumer repo stores pointers, versioned derived docs and project-local engineering artifacts. RAG is optional permission-scoped retrieval, not a replacement for a system of record. The `aih` CLI does not ingest data or run agents.
14
+
15
+ For new/material journeys, continue with `product-to-solution.md` after resolving the relevant scope.
16
+ Use reviewed MVP/outcomes and optional Architecture/Delivery to assess solution/dependencies before
17
+ OpenSpec implementation. Existing approved sources remain canonical; small clear changes stay compact.
@@ -11,3 +11,7 @@ Use this when the developer starts with an idea, including a solo project. Produ
11
11
  7. **Changes:** when the idea changes, perform impact analysis and propose minimal document diffs; never silently overwrite approved requirements.
12
12
 
13
13
  Deliverables are **consumer-owned** `docs/discovery/`, `docs/product/` and `openspec/` files. For Confluence publishing, preserve URLs/page versions and request explicit write permission; the Harness CLI does not connect or publish to Confluence.
14
+
15
+ For new/material journeys, continue with `product-to-solution.md` after resolving the relevant scope.
16
+ Use reviewed MVP/outcomes and optional Architecture/Delivery to assess solution/dependencies before
17
+ OpenSpec implementation. Existing approved sources remain canonical; small clear changes stay compact.
@@ -0,0 +1,15 @@
1
+ # BA → PO → Solution → Delivery
2
+
3
+ Use for a new product or material feature. For a small clear fix, use the Core compact path.
4
+ 1. BA preserves idea/approved document revisions, identifies users, business rules and contradictions;
5
+ load existing business-analysis/use-case/AC skills. Source/output register is authoritative.
6
+ 2. PO uses mvp-definition and product-outcomes to propose a complete journey, in/out scope, minimum
7
+ quality and success evidence. Resolve consequential unknowns with actual owner; no self-approval.
8
+ 3. Pass reviewed FR/US/AC/NFR and decision status to installed Architecture discovery-to-design.
9
+ If Architecture is not selected, Core spec uses the same constraints and records relevant choices.
10
+ 4. Pass accepted solution boundaries, AC, dependencies and actual estimates to optional Delivery planning;
11
+ absent Delivery, record a proportional task order in the OpenSpec change.
12
+ 5. UI/UX can iterate with SA/PO; adapters implement accepted technical decisions. Verify and review
13
+ against requirements/ADRs. Evidence and scope changes loop back to the owning role.
14
+ 6. Carry source revisions, IDs, output paths, decisions, unresolved questions, actual checks and next
15
+ owner in every handoff. A role label never creates a worker or approves a stakeholder decision.
package/cli/agents.js ADDED
@@ -0,0 +1,165 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { safe, stat, readJson, isObject, SLUG, SOURCE, HarnessError } from './files.js';
4
+
5
+ export const AGENT_CONFIG = '.harness/agents.json';
6
+ export const DEFAULT_AGENTS = 'config/agents.default.json';
7
+ export const PRESETS = ['recommended', 'economy', 'custom'];
8
+ export const REASONING = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
9
+ const ROLE = /^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*$/;
10
+ const text = value => typeof value === 'string' && value.trim() === value && value.length > 0
11
+ && value.length <= 256 && !/[\x00-\x1f\x7f]/.test(value);
12
+ const requireThat = (condition, label) => { if (!condition) throw new HarnessError('Invalid agent configuration: ' + label); };
13
+ function fields(value, allowed, required, label) {
14
+ requireThat(isObject(value) && Object.keys(value).every(key => allowed.includes(key))
15
+ && required.every(key => Object.hasOwn(value, key)), label + ' fields');
16
+ }
17
+ function reference(value, label) {
18
+ requireThat(text(value), label + ' must be nonempty single-line text (max 256 characters)');
19
+ if (value.startsWith('@')) requireThat(SLUG.test(value.slice(1)), label + ' alias syntax');
20
+ else requireThat(!/\s/.test(value), label + ' model ID must not contain whitespace');
21
+ }
22
+ function provider(value, label) { requireThat(typeof value === 'string' && SLUG.test(value), label + ' provider ID'); }
23
+ function reasoning(value, label) { requireThat(REASONING.includes(value), label + ' reasoning level'); }
24
+
25
+ export function resolveModel(config, referenceValue, visited = []) {
26
+ if (!referenceValue.startsWith('@')) return { model: referenceValue, provider: config.provider };
27
+ const alias = referenceValue.slice(1);
28
+ requireThat(Object.hasOwn(config.models, alias), 'unknown alias @' + alias);
29
+ requireThat(!visited.includes(alias), 'cyclic alias @' + alias);
30
+ const descriptor = config.models[alias];
31
+ const next = typeof descriptor === 'string' ? descriptor : descriptor.model;
32
+ const resolved = resolveModel(config, next, [...visited, alias]);
33
+ return { ...resolved, provider: typeof descriptor === 'object' ? descriptor.provider ?? resolved.provider : resolved.provider };
34
+ }
35
+
36
+ export function validateAgentConfig(config) {
37
+ fields(config, ['schema_version', 'provider', 'defaultModel', 'defaultReasoning', 'models', 'agents', 'execution', 'hosts'],
38
+ ['schema_version', 'provider', 'defaultModel', 'defaultReasoning', 'models', 'agents', 'execution'], 'root');
39
+ requireThat(config.schema_version === 1, 'unsupported schema_version');
40
+ provider(config.provider, 'default'); reference(config.defaultModel, 'defaultModel');
41
+ reasoning(config.defaultReasoning, 'default');
42
+ requireThat(isObject(config.models) && Object.keys(config.models).length <= 64, 'models map (max 64 aliases)');
43
+ for (const [alias, descriptor] of Object.entries(config.models)) {
44
+ requireThat(SLUG.test(alias), 'alias ID');
45
+ if (typeof descriptor === 'string') reference(descriptor, 'models.' + alias);
46
+ else {
47
+ fields(descriptor, ['model', 'provider'], ['model'], 'models.' + alias);
48
+ reference(descriptor.model, 'models.' + alias);
49
+ if (descriptor.provider !== undefined) provider(descriptor.provider, 'models.' + alias);
50
+ }
51
+ }
52
+ resolveModel(config, config.defaultModel);
53
+ for (const alias of Object.keys(config.models)) resolveModel(config, '@' + alias);
54
+ requireThat(isObject(config.agents) && Object.keys(config.agents).length <= 128, 'agents map (max 128 roles)');
55
+ for (const [id, agent] of Object.entries(config.agents)) {
56
+ requireThat(ROLE.test(id), 'role ID must use namespace:role');
57
+ fields(agent, ['model', 'provider', 'reasoning', 'enabled'], [], id);
58
+ if (agent.model !== undefined) { reference(agent.model, id); resolveModel(config, agent.model); }
59
+ if (agent.provider !== undefined) provider(agent.provider, id);
60
+ if (agent.reasoning !== undefined) reasoning(agent.reasoning, id);
61
+ requireThat(agent.enabled === undefined || typeof agent.enabled === 'boolean', id + ' enabled');
62
+ }
63
+ const execution = config.execution;
64
+ fields(execution, ['mode', 'maxConcurrentAgents', 'maxRounds', 'autoEscalateToPremium'],
65
+ ['mode', 'maxConcurrentAgents', 'maxRounds', 'autoEscalateToPremium'], 'execution');
66
+ requireThat(['single-agent', 'hybrid', 'multi-agent'].includes(execution.mode), 'execution mode');
67
+ requireThat(Number.isSafeInteger(execution.maxConcurrentAgents) && execution.maxConcurrentAgents >= 1
68
+ && execution.maxConcurrentAgents <= 32, 'maxConcurrentAgents must be an integer from 1 to 32');
69
+ requireThat(execution.mode !== 'single-agent' || execution.maxConcurrentAgents === 1, 'single-agent requires maxConcurrentAgents=1');
70
+ requireThat(Number.isSafeInteger(execution.maxRounds) && execution.maxRounds >= 1 && execution.maxRounds <= 20,
71
+ 'maxRounds must be an integer from 1 to 20');
72
+ requireThat(execution.autoEscalateToPremium === false, 'autoEscalateToPremium must be false; select premium explicitly');
73
+ const hosts = config.hosts ?? {};
74
+ requireThat(isObject(hosts) && Object.keys(hosts).length <= 16, 'hosts map (max 16 profiles)');
75
+ for (const [id, profile] of Object.entries(hosts)) {
76
+ requireThat(SLUG.test(id), 'host ID'); fields(profile, ['models', 'reasoning'], [], 'host ' + id);
77
+ for (const key of ['models', 'reasoning']) {
78
+ const map = profile[key] ?? {};
79
+ requireThat(isObject(map) && Object.keys(map).length <= 128, 'host ' + id + ' ' + key + ' map');
80
+ for (const [from, to] of Object.entries(map)) {
81
+ requireThat(key === 'models' ? text(from) && from.includes('/') : REASONING.includes(from), 'host mapping key');
82
+ requireThat(text(to) && !to.startsWith('@'), 'host mapping target must be explicit native text');
83
+ }
84
+ }
85
+ }
86
+ return config;
87
+ }
88
+
89
+ function readConfiguration(root, relative) {
90
+ const filename = safe(root, relative), info = stat(filename);
91
+ if (!info) return null;
92
+ requireThat(info.isFile() && info.size <= 1024 * 1024, relative + ' must be a file up to 1 MiB');
93
+ let config;
94
+ try { config = JSON.parse(fs.readFileSync(filename, 'utf8')); }
95
+ catch { throw new HarnessError('Invalid JSON in ' + relative); }
96
+ return validateAgentConfig(config);
97
+ }
98
+ export function loadAgentConfig(root) { return readConfiguration(root, AGENT_CONFIG); }
99
+
100
+ export function agentDefaults(source = SOURCE, preset = 'recommended') {
101
+ requireThat(PRESETS.includes(preset), 'unknown preset; use recommended, economy or custom');
102
+ // Older alternate bundles remain usable; the packaged CLI supplies the new canonical seed.
103
+ const config = structuredClone(readConfiguration(source, DEFAULT_AGENTS) ?? readConfiguration(SOURCE, DEFAULT_AGENTS));
104
+ requireThat(config !== null, 'missing canonical default');
105
+ if (preset === 'economy') {
106
+ config.defaultModel = '@economy'; config.defaultReasoning = 'low';
107
+ config.execution.mode = 'single-agent'; config.execution.maxConcurrentAgents = 1;
108
+ for (const agent of Object.values(config.agents)) { agent.model = '@economy'; agent.reasoning = 'low'; }
109
+ }
110
+ return validateAgentConfig(config);
111
+ }
112
+
113
+ export function registeredRoles(source, capabilities = []) {
114
+ const roles = {};
115
+ const folder = path.dirname(safe(source, 'core/agents/.role-index'));
116
+ for (const name of fs.readdirSync(folder).filter(name => name.endsWith('.md')).sort()) {
117
+ roles['core:' + name.slice(0, -3)] = 'core/agents/' + name;
118
+ }
119
+ for (const id of capabilities) {
120
+ const metadata = readJson(safe(source, `capabilities/${id}/capability.json`));
121
+ for (const file of metadata.agents ?? []) {
122
+ const role = id + ':' + path.basename(file, '.md');
123
+ roles[role] = `capabilities/${id}/${file}`;
124
+ }
125
+ }
126
+ return roles;
127
+ }
128
+
129
+ export function resolveAgent(config, id) {
130
+ const agent = Object.hasOwn(config.agents, id) ? config.agents[id] : {};
131
+ const resolved = resolveModel(config, agent.model ?? config.defaultModel);
132
+ return { id, ...resolved, provider: agent.provider ?? resolved.provider,
133
+ reasoning: agent.reasoning ?? config.defaultReasoning, enabled: agent.enabled !== false,
134
+ source: Object.hasOwn(config.agents, id) ? 'role' : 'project-default' };
135
+ }
136
+
137
+ export function agentPlan(root, host) {
138
+ if (host !== undefined) requireThat(typeof host === 'string' && SLUG.test(host), 'host ID');
139
+ const config = loadAgentConfig(root);
140
+ const presets = [{ label: 'A', value: 'recommended', description: 'Sol, hybrid, at most two concurrent agents when supported.' },
141
+ { label: 'B', value: 'economy', description: 'Economy model, single-agent roles.' },
142
+ { label: 'C', value: 'custom', description: 'Edit provider/model/reasoning and role/host mappings explicitly.' }];
143
+ const note = 'Read-only declaration. No provider/model probing, native config writes, agent spawning, concurrency/token enforcement or usage tracking. Verify model access, effort and delegation in the actual host before execution.';
144
+ if (!config) return { status: 'not-configured', configured: false, presets, roles: [], note };
145
+ const lock = readJson(safe(root, '.harness/lock.json'));
146
+ const registry = registeredRoles(path.join(root, '.harness'), lock.capabilities ?? []);
147
+ const ids = [...new Set([...Object.keys(registry), ...Object.keys(config.agents)])].sort();
148
+ const profile = config.hosts?.[host] ?? {};
149
+ const roles = ids.map(id => {
150
+ const requested = resolveAgent(config, id);
151
+ const active = requested.enabled && Object.hasOwn(registry, id);
152
+ const mapping = profile.models?.[requested.provider + '/' + requested.model];
153
+ const incompatible = !mapping && ((host === 'claude-code' &&
154
+ (!['anthropic', 'bedrock', 'vertex'].includes(requested.provider) || requested.model.startsWith('gpt-'))) ||
155
+ (host === 'gemini-cli' && (!['google', 'vertex'].includes(requested.provider) || requested.model.startsWith('gpt-'))));
156
+ return { ...requested, active, contract: registry[id] ? '.harness/' + registry[id] : null,
157
+ host_model: mapping ?? requested.model, host_reasoning: profile.reasoning?.[requested.reasoning] ?? requested.reasoning,
158
+ mapping: mapping ? 'explicit-user-host-mapping' : 'requested',
159
+ availability: active && incompatible ? 'incompatible-provider' : 'unverified',
160
+ runtime_verified: false };
161
+ });
162
+ return { status: roles.some(role => role.active && role.availability === 'incompatible-provider') ? 'host-incompatible' : 'requires-host-verification',
163
+ configured: true, host: host ?? null, presets, execution: config.execution, roles,
164
+ missing_mappings: Object.keys(registry).filter(id => !Object.hasOwn(config.agents, id)), note };
165
+ }
@@ -0,0 +1,170 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { safe, stat, isObject, SLUG, HarnessError } from './files.js';
4
+ import { agentPlan } from './agents.js';
5
+
6
+ export const ENVIRONMENT = '.harness/environment.json';
7
+ export const MACHINE = '.harness/machine.json';
8
+ export const emptyEnvironment = () => ({ schema_version: 1, tools: {}, files: {}, commands: {} });
9
+ const validText = s => typeof s === 'string' && s.trim().length > 0 && !/[\0\r\n]/.test(s);
10
+ const own = (o, k) => Object.hasOwn(o, k);
11
+ function requireThat(ok, message) { if (!ok) throw new HarnessError(message); }
12
+
13
+ export function readConfig(root, name) {
14
+ const p = safe(root, name), info = stat(p);
15
+ if (!info) return null;
16
+ requireThat(info.size <= 1024 * 1024, 'Configuration exceeds 1 MiB: ' + name);
17
+ // Never include parser input (possibly private machine paths) in diagnostics.
18
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
19
+ catch { throw new HarnessError('Invalid JSON: ' + name); }
20
+ }
21
+ function keys(value, allowed, label) {
22
+ requireThat(isObject(value) && Object.keys(value).every(k => allowed.includes(k)), 'Invalid fields: ' + label);
23
+ }
24
+ export function loadEnvironment(root) {
25
+ const shared = readConfig(root, ENVIRONMENT);
26
+ const machine = readConfig(root, MACHINE);
27
+ if (shared === null) {
28
+ requireThat(machine === null, 'Machine configuration requires environment.json');
29
+ return { configured: false, shared: emptyEnvironment(), machine: null };
30
+ }
31
+ keys(shared, ['schema_version', 'tools', 'files', 'commands'], ENVIRONMENT);
32
+ requireThat(shared.schema_version === 1, 'Unsupported environment schema');
33
+ for (const group of ['tools', 'files', 'commands']) {
34
+ requireThat(isObject(shared[group]), 'Missing environment map: ' + group);
35
+ for (const [id, value] of Object.entries(shared[group])) {
36
+ requireThat(SLUG.test(id), 'Invalid environment identifier');
37
+ const label = group + '/' + id;
38
+ if (group === 'commands') {
39
+ keys(value, ['tool', 'args'], label);
40
+ requireThat(validText(value.tool) && own(shared.tools, value.tool), 'Unknown command tool: ' + id);
41
+ requireThat(Array.isArray(value.args) && value.args.every(a => typeof a === 'string' && !/[\0\r\n]/.test(a)), 'Invalid arguments: ' + id);
42
+ } else {
43
+ const field = group === 'tools' ? 'executable' : 'path';
44
+ keys(value, [field, 'required'], label);
45
+ requireThat(validText(value[field]), 'Missing path: ' + label);
46
+ requireThat(value.required === undefined || typeof value.required === 'boolean', 'Invalid required flag: ' + label);
47
+ }
48
+ }
49
+ }
50
+ if (machine !== null) {
51
+ keys(machine, ['schema_version', 'tools', 'files'], MACHINE);
52
+ requireThat(machine.schema_version === 1, 'Unsupported machine schema');
53
+ for (const group of ['tools', 'files']) {
54
+ if (machine[group] === undefined) continue;
55
+ requireThat(isObject(machine[group]), 'Invalid machine map: ' + group);
56
+ const field = group === 'tools' ? 'executable' : 'path';
57
+ for (const [id, value] of Object.entries(machine[group])) {
58
+ requireThat(SLUG.test(id) && own(shared[group], id), 'Machine override must reference a declared ' + group + ' entry');
59
+ keys(value, [field], 'machine ' + group + '/' + id);
60
+ requireThat(validText(value[field]), 'Invalid machine override: ' + group + '/' + id);
61
+ }
62
+ }
63
+ }
64
+ return { configured: true, shared, machine };
65
+ }
66
+
67
+ function expand(value, env) {
68
+ const missing = [];
69
+ const resolved = value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, key) => {
70
+ if (!own(env, key) || !validText(env[key])) { missing.push(key); return ''; }
71
+ return env[key];
72
+ });
73
+ return { resolved, missing: [...new Set(missing)], invalid: /\$\{/.test(resolved) };
74
+ }
75
+ function pathStatus(value, root, executable, env) {
76
+ if ((process.platform !== 'win32' && /^[A-Za-z]:[\\/]|^\\\\/.test(value)) ||
77
+ (process.platform === 'win32' && value.startsWith('/'))) return 'platform-mismatch';
78
+ if (value.startsWith('~')) return 'unsupported-path'; // No implicit shell expansion.
79
+ const bare = !path.isAbsolute(value) && !/[\\/]/.test(value);
80
+ const candidates = executable && bare
81
+ ? (env.PATH || '').split(path.delimiter).filter(Boolean).filter(path.isAbsolute).map(dir => path.join(dir, value))
82
+ : [path.resolve(root, value)];
83
+ const extensions = executable && process.platform === 'win32' && !path.extname(value)
84
+ ? (env.PATHEXT || '.EXE;.CMD;.BAT').split(';').filter(e => /^\.[A-Za-z0-9]+$/.test(e)) : [''];
85
+ let notExecutable = false;
86
+ for (const candidate of candidates) for (const ext of extensions) {
87
+ try {
88
+ const p = candidate + ext;
89
+ if (!fs.statSync(p).isFile()) continue;
90
+ fs.accessSync(p, executable && process.platform !== 'win32' ? fs.constants.X_OK : fs.constants.F_OK);
91
+ return 'present';
92
+ } catch (e) { if (e.code === 'EACCES') notExecutable = true; }
93
+ }
94
+ return notExecutable ? 'not-accessible' : 'missing';
95
+ }
96
+
97
+ export function environmentReport(root, env = process.env) {
98
+ const { configured, shared, machine } = loadEnvironment(root);
99
+ const entries = [];
100
+ for (const group of ['tools', 'files']) for (const [id, value] of Object.entries(shared[group])) {
101
+ const field = group === 'tools' ? 'executable' : 'path';
102
+ const override = machine?.[group]?.[id];
103
+ const expanded = expand(override ? override[field] : value[field], env);
104
+ entries.push({ group, id, source: override ? 'machine' : 'project', required: value.required !== false,
105
+ status: expanded.missing.length ? 'missing-env' : expanded.invalid ? 'invalid-reference'
106
+ : pathStatus(expanded.resolved, root, group === 'tools', env),
107
+ missing_variables: expanded.missing });
108
+ }
109
+ // Arguments are references only; do not execute or print resolved values.
110
+ for (const [id, command] of Object.entries(shared.commands)) {
111
+ const args = command.args.map(a => expand(a, env));
112
+ const missing = [...new Set(args.flatMap(a => a.missing))];
113
+ entries.push({ group: 'commands', id, source: 'project', required: shared.tools[command.tool].required !== false,
114
+ status: missing.length ? 'missing-env' : args.some(a => a.invalid) ? 'invalid-reference' : 'declared',
115
+ missing_variables: missing });
116
+ }
117
+ const problems = entries.filter(e => e.required && !['present', 'declared'].includes(e.status))
118
+ .map(e => e.group + '/' + e.id + ': ' + e.status);
119
+ return { configured, machine_present: machine !== null, entries, problems,
120
+ note: 'Presence/reference checks only. No commands executed; values and arguments are omitted. Never store credentials here.' };
121
+ }
122
+
123
+ export function setupPlan(root, adapters, env = process.env, host) {
124
+ const environment = environmentReport(root, env);
125
+ const configuredTools = environment.entries.some(e => e.group === 'tools');
126
+ const questions = [];
127
+ if (adapters.includes('java-spring') && !configuredTools) {
128
+ const wrapper = process.platform === 'win32' ? 'mvnw.cmd' : 'mvnw';
129
+ const hasWrapper = Boolean(stat(safe(root, wrapper)));
130
+ const gradle = ['gradlew', 'gradlew.bat', 'build.gradle', 'build.gradle.kts'].some(p => Boolean(stat(safe(root, p))));
131
+ if (gradle) {
132
+ questions.push({ id: 'build-tool', question: 'Which build tool does this project use?',
133
+ options: [{ label: 'A', value: 'gradle', description: 'Use the existing Gradle setup.' },
134
+ { label: 'B', value: 'maven', description: 'Use Maven only if project conventions require it.' }],
135
+ recommendation: 'A', reason: 'Gradle files exist; confirm the project convention.', allow_free_text: true });
136
+ } else questions.push({ id: 'maven-mode', question: 'How should Maven be invoked?',
137
+ options: [{ label: 'A', value: 'wrapper', description: 'Use the repository Maven Wrapper.' },
138
+ { label: 'B', value: 'path', description: 'Use Maven available on PATH.' },
139
+ { label: 'C', value: 'custom', description: 'Use an internal Maven executable and optional settings file.' }],
140
+ recommendation: hasWrapper ? 'A' : null,
141
+ reason: hasWrapper ? 'A wrapper exists for this OS; review it before execution.' : 'No wrapper found for this OS; ask which Maven the team uses.',
142
+ allow_free_text: true });
143
+ }
144
+ if (!environment.configured || !environment.entries.length) questions.push({
145
+ id: 'project-context', question: 'Where are the authoritative project requirements and conventions?',
146
+ options: [{ label: 'A', value: 'repository', description: 'Use existing repository documentation and build files.' },
147
+ { label: 'B', value: 'external', description: 'Use authorized BA/Confluence sources with revisions.' },
148
+ { label: 'C', value: 'idea', description: 'Start from the user idea and clarify the scope.' }],
149
+ recommendation: null, reason: 'The host must inspect existing local.md and sources before recommending.',
150
+ allow_free_text: true
151
+ }, {
152
+ id: 'project-mode', question: 'What is the scope of the affected code?',
153
+ options: [{ label: 'A', value: 'maintain', description: 'Extend existing modules using their conventions and shared APIs.' },
154
+ { label: 'B', value: 'new', description: 'Start a new application with a minimal agreed adapter baseline.' },
155
+ { label: 'C', value: 'mixed', description: 'Add a new module while preserving repository-wide contracts.' }],
156
+ recommendation: null,
157
+ reason: 'Inspect the task, local.md and neighboring code; stack detection or environment configuration does not establish project mode. Skip if already known.',
158
+ allow_free_text: true
159
+ });
160
+ return { status: environment.problems.length ? 'blocked' : 'planned', environment, questions,
161
+ agent_configuration: agentPlan(root, host),
162
+ next_steps: ['Read AGENTS.md, .harness/local.md and the installed project-setup workflow.',
163
+ 'Apply installed Core project-conventions.md and relevant adapter conventions; establish mode per affected module even when no setup questions are suggested.',
164
+ 'Inspect comparable code and search public shared APIs before creating files, dependencies or abstractions; record source paths and reuse/new-code rationale.',
165
+ 'Skip answered questions; ask at most 1–3 consequential questions with evidence-based recommendations.',
166
+ 'Review agents.json provider/model/reasoning and preset choices; verify access/effort/delegation in the actual host before execution. No automatic premium fallback.',
167
+ 'Record shared decisions in local.md/environment.json; put only machine paths in ignored machine.json.',
168
+ 'Run aih doctor, then use the reviewed project commands through your AI host.'],
169
+ note: 'Read-only host-facing suggestions, not an interactive terminal wizard or approval.' };
170
+ }
package/cli/harness.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import fs from 'node:fs';
2
+ import { emptyEnvironment, environmentReport } from './environment.js';
3
+ import { AGENT_CONFIG, agentDefaults, loadAgentConfig } from './agents.js';
2
4
  import path from 'node:path';
3
5
  import { randomUUID } from 'node:crypto';
4
6
  import { loadAdapter } from './adapter-schema.js';
@@ -65,6 +67,7 @@ export function bundle(source, config) {
65
67
  files[dest] = fs.readFileSync(safe(source, `adapters/${a}/skills/${name}/SKILL.md`));
66
68
  }
67
69
  }
70
+ files['.harness/templates/agents.default.json'] = encode(agentDefaults(source));
68
71
  for (const c of capabilities) {
69
72
  const info = loadCapability(source, c);
70
73
  for (const name of info.skills) {
@@ -156,12 +159,15 @@ export function transaction(root, changes, writer = atomicWrite) {
156
159
  }
157
160
  }
158
161
 
159
- export function install(root, source, { command = 'init', adapters, capabilities, dryRun = false, allowMajor = false, writer } = {}) {
162
+ export function install(root, source, { command = 'init', adapters, capabilities, agentPreset, dryRun = false, allowMajor = false, writer } = {}) {
160
163
  assertSeparate(root, source);
161
164
  return guard(root, () => {
162
165
  const hasLock = Boolean(stat(safe(root, LOCK)));
163
166
  if (command === 'update' && !hasLock) throw new HarnessError('Not installed; run init first');
164
167
  const old = hasLock ? loadLock(root) : null;
168
+ const existingAgents = loadAgentConfig(root);
169
+ if (agentPreset !== undefined && command !== 'init') throw new HarnessError('Agent preset is supported only by init');
170
+ if (agentPreset !== undefined && existingAgents) throw new HarnessError('agents.json is project-owned; edit it explicitly instead of applying an init preset');
165
171
  const config = projectConfig(root, adapters, capabilities, hasLock);
166
172
  const { files, lock, warnings } = bundle(source, config);
167
173
  if (old) {
@@ -185,11 +191,20 @@ export function install(root, source, { command = 'init', adapters, capabilities
185
191
  }
186
192
  if (old) for (const name of Object.keys(old.files)) if (!Object.hasOwn(files, name)) changes[name] = null;
187
193
  const seeds = {
194
+ [AGENT_CONFIG]: encode(agentDefaults(source, agentPreset ?? 'recommended')),
188
195
  [CONFIG]: encode(config),
196
+ '.harness/environment.json': encode(emptyEnvironment()),
189
197
  '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'),
198
+ '.harness/local.md': Buffer.from('# Project overrides\n\nRecord project facts and reviewed exceptions here. Never weaken security boundaries.\n\n## Development conventions\nRecord scope/mode (new, maintain or mixed), authoritative source paths, module boundaries, reusable public APIs, naming/error/test conventions and configured checks. Link existing guides instead of duplicating them. Unknowns are not approved defaults; the AI host inspects evidence and asks only consequential questions.\n'),
191
199
  };
192
200
  for (const [name, data] of Object.entries(seeds)) if (!stat(safe(root, name))) changes[name] = data;
201
+ // Project-owned ignore file: preserve all prior rules, place our anchored rule last.
202
+ // This does not remove an already tracked machine file from the Git index.
203
+ const ignoreName = '.harness/.gitignore', ignorePath = safe(root, ignoreName);
204
+ const ignore = stat(ignorePath) ? fs.readFileSync(ignorePath, 'utf8') : '';
205
+ if (ignore.trimEnd().split(/\r?\n/).at(-1) !== '/machine.json') {
206
+ changes[ignoreName] = Buffer.from(ignore + (ignore && !ignore.endsWith('\n') ? '\n' : '') + '/machine.json\n');
207
+ }
193
208
  const newLock = encode(lock);
194
209
  if (!hasLock || !fs.readFileSync(safe(root, LOCK)).equals(newLock)) changes[LOCK] = newLock;
195
210
  const plan = Object.entries(changes).map(([name, data]) => ({ path: name, action: data === null ? 'delete' : 'write' }));
@@ -201,6 +216,7 @@ export function install(root, source, { command = 'init', adapters, capabilities
201
216
  export function check(root) {
202
217
  const lock = loadLock(root), config = projectConfig(root, undefined, undefined, true);
203
218
  const problems = drift(root, lock);
219
+ loadAgentConfig(root); // Optional on legacy installs; validate user config without managing its bytes.
204
220
  if (JSON.stringify([...config.adapters].sort()) !== JSON.stringify([...lock.adapters].sort())) problems.push(`${CONFIG}: adapter selection changed; run update`);
205
221
  if (JSON.stringify([...(config.capabilities ?? [])].sort()) !== JSON.stringify([...(lock.capabilities ?? [])].sort())) problems.push(`${CONFIG}: capability selection changed; run update`);
206
222
  return { status: problems.length ? 'error' : 'ok', version: lock.version, problems };
@@ -214,6 +230,10 @@ export function newChange(root, name) {
214
230
  safe(root, `openspec/changes/${name}/proposal.md`);
215
231
  if (stat(folder)) throw new HarnessError(`Change already exists: ${name}`);
216
232
  const mapping = { 'proposal.md': 'proposal.md', 'design.md': 'design.md', 'tasks.md': 'tasks.md', 'spec.md': `specs/${name}/spec.md`, '.openspec.yaml': '.openspec.yaml' };
233
+ const locked = loadLock(root);
234
+ for (const name of ['decisions.json', 'verification.json']) {
235
+ if (Object.hasOwn(locked.files, '.harness/templates/openspec/' + name)) mapping[name] = name;
236
+ }
217
237
  const changes = {};
218
238
  for (const [src, dest] of Object.entries(mapping)) {
219
239
  const text = fs.readFileSync(safe(root, `.harness/templates/openspec/${src}`), 'utf8');
@@ -224,20 +244,28 @@ export function newChange(root, name) {
224
244
  });
225
245
  }
226
246
 
227
- // Product drafts are project-owned and never added to the managed bundle.
228
- export function newDoc(root, name) {
247
+ // Capability drafts are project-owned and never added to the managed bundle.
248
+ export function newDoc(root, name, capability = 'product') {
229
249
  if (!SLUG.test(name)) throw new HarnessError('Document type must be a lowercase hyphenated slug');
250
+ if (!SLUG.test(capability)) throw new HarnessError('Capability must be a lowercase hyphenated slug');
230
251
  return guard(root, () => {
231
252
  if (check(root).status !== 'ok') throw new HarnessError('Fix installation drift before creating a document');
232
253
  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';
254
+ if (!(lock.capabilities ?? []).includes(capability)) {
255
+ throw new HarnessError(capability === 'product' ? 'Product capability is not installed' : `Capability is not installed: ${capability}`);
256
+ }
257
+ const sourceName = `.harness/capabilities/${capability}/templates/${name}.md`;
258
+ if (!Object.hasOwn(lock.files, `.harness/capabilities/${capability}/capability.json`)) {
259
+ throw new HarnessError(`Capability metadata is not locked: ${capability}`);
260
+ }
261
+ const declared = loadCapability(path.join(root, '.harness'), capability).templates;
262
+ if (!declared.includes(`templates/${name}.md`)) throw new HarnessError(`Unknown document type for ${capability}: ${name}`);
263
+ if (!Object.hasOwn(lock.files, sourceName)) throw new HarnessError(`Document template is not locked: ${capability}/${name}`);
264
+ const folder = capability === 'product' && ['idea', 'research'].includes(name) ? 'discovery' : capability;
237
265
  const outputName = `docs/${folder}/${name}.md`;
238
266
  if (stat(safe(root, outputName))) throw new HarnessError(`Document already exists: ${outputName}`);
239
267
  transaction(root, { [outputName]: fs.readFileSync(safe(root, sourceName)) });
240
- return { status: 'ok', type: name, path: outputName,
268
+ return { status: 'ok', type: name, capability, path: outputName,
241
269
  note: 'Template only; populate from authorized evidence and obtain human approval.' };
242
270
  });
243
271
  }
@@ -251,5 +279,7 @@ export function doctor(root) {
251
279
  catch { return false; }
252
280
  }));
253
281
  }
254
- return { ...check(root), tools, note: 'Tool presence only; no commands executed or compatibility claim made' };
282
+ const state = check(root), environment = environmentReport(root);
283
+ return { ...state, status: state.status !== 'ok' || environment.problems.length ? 'error' : 'ok',
284
+ tools, environment, note: 'Tool presence only; no commands executed or compatibility claim made. Configured project tools take precedence over generic PATH hints.' };
255
285
  }