@manhd_d/ai-harness 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +28 -37
- package/adapters/ai-python/adapter.yaml +1 -1
- package/adapters/ai-python/rules.md +3 -0
- package/adapters/ai-python/skills/harness-ai-python-rag-access/SKILL.md +1 -0
- package/adapters/ai-python/skills/harness-ai-python-tool-safety/SKILL.md +1 -0
- package/adapters/astro/adapter.yaml +3 -5
- package/adapters/astro/checks/verification.yaml +6 -6
- package/adapters/astro/evals/cases.json +5 -5
- package/adapters/astro/rules/architecture.md +2 -8
- package/adapters/astro/rules/deployment.md +2 -8
- package/adapters/astro/rules.md +5 -14
- package/adapters/astro/skills/harness-astro-islands/SKILL.md +3 -2
- package/adapters/astro/skills/harness-astro-performance/SKILL.md +1 -0
- package/adapters/astro/skills/harness-astro-project-architecture/SKILL.md +9 -19
- package/adapters/astro/skills/harness-astro-seo/SKILL.md +1 -0
- package/adapters/java-spring/adapter.yaml +2 -4
- package/adapters/java-spring/rules/database.md +2 -2
- package/adapters/java-spring/rules/performance.md +2 -2
- package/adapters/java-spring/rules.md +4 -1
- package/adapters/java-spring/skills/harness-java-spring-spring-api/SKILL.md +1 -0
- package/adapters/kubernetes/adapter.yaml +1 -1
- package/adapters/kubernetes/rules.md +3 -0
- package/adapters/kubernetes/skills/harness-kubernetes-helm-release/SKILL.md +1 -0
- package/adapters/kubernetes/skills/harness-kubernetes-rollout-safety/SKILL.md +1 -0
- package/adapters/kubernetes/skills/harness-kubernetes-workload-review/SKILL.md +1 -0
- package/adapters/react-native/adapter.yaml +1 -1
- package/adapters/react-native/rules.md +3 -0
- package/adapters/react-native/skills/harness-react-native-behavior-testing/SKILL.md +1 -0
- package/adapters/react-native/skills/harness-react-native-device-permissions/SKILL.md +1 -0
- package/adapters/react-native/skills/harness-react-native-offline-sync/SKILL.md +1 -0
- package/adapters/react-native/skills/harness-react-native-screen-delivery/SKILL.md +1 -0
- package/adapters/react-nextjs/adapter.yaml +1 -1
- package/adapters/react-nextjs/rules.md +3 -0
- package/adapters/react-nextjs/skills/harness-react-nextjs-accessible-ui/SKILL.md +1 -0
- package/adapters/react-nextjs/skills/harness-react-nextjs-behavior-testing/SKILL.md +1 -0
- package/adapters/react-nextjs/skills/harness-react-nextjs-feature-delivery/SKILL.md +1 -0
- package/adapters/react-nextjs/skills/harness-react-nextjs-server-boundaries/SKILL.md +1 -0
- package/bin/aih.js +1 -1
- package/capabilities/analysis/capability.json +48 -0
- package/capabilities/analysis/checks/verification.json +16 -0
- package/capabilities/analysis/evals/cases.json +15 -0
- package/capabilities/architecture/agents/architecture-reviewer.md +5 -0
- package/capabilities/architecture/agents/solution-architect.md +5 -0
- package/capabilities/architecture/capability.json +1 -1
- package/capabilities/delivery/agents/project-manager.md +10 -11
- package/capabilities/delivery/capability.json +8 -2
- package/capabilities/delivery/templates/delivery-plan.md +2 -23
- package/capabilities/discovery/capability.json +37 -0
- package/capabilities/discovery/checks/verification.json +16 -0
- package/capabilities/discovery/evals/cases.json +15 -0
- package/capabilities/product/agents/business-analyst.md +5 -0
- package/capabilities/product/agents/product-manager.md +5 -0
- package/capabilities/product/agents/product-owner.md +5 -0
- package/capabilities/product/agents/project-manager.md +9 -14
- package/capabilities/product/agents/requirements-reviewer.md +5 -0
- package/capabilities/product/capability.json +8 -2
- package/capabilities/product/common/document-contract.md +10 -0
- package/capabilities/product/evals/cases.json +11 -0
- package/capabilities/product/skills/harness-product-acceptance-criteria/SKILL.md +4 -7
- package/capabilities/product/skills/harness-product-backlog-prioritization/SKILL.md +2 -6
- package/capabilities/product/skills/harness-product-brd-generation/SKILL.md +2 -6
- package/capabilities/product/skills/harness-product-business-analysis/SKILL.md +7 -10
- package/capabilities/product/skills/harness-product-document-review/SKILL.md +2 -6
- package/capabilities/product/skills/harness-product-idea-discovery/SKILL.md +2 -6
- package/capabilities/product/skills/harness-product-knowledge-analysis/SKILL.md +2 -6
- package/capabilities/product/skills/harness-product-mvp-definition/SKILL.md +1 -2
- package/capabilities/product/skills/harness-product-prd-generation/SKILL.md +2 -6
- package/capabilities/product/skills/harness-product-product-outcomes/SKILL.md +1 -2
- package/capabilities/product/skills/harness-product-product-research/SKILL.md +2 -6
- package/capabilities/product/skills/harness-product-srs-generation/SKILL.md +2 -6
- package/capabilities/product/skills/harness-product-techspec/SKILL.md +1 -1
- package/capabilities/product/skills/harness-product-traceability/SKILL.md +2 -6
- package/capabilities/product/skills/harness-product-use-case-generation/SKILL.md +2 -6
- package/capabilities/product/skills/harness-product-user-stories/SKILL.md +2 -6
- package/capabilities/product/templates/delivery-plan.md +3 -17
- package/cli/adapters.js +9 -5
- package/cli/agents.js +45 -9
- package/cli/capabilities.js +39 -8
- package/cli/checkpoint.js +57 -0
- package/cli/claude.js +40 -0
- package/cli/comparison.js +46 -0
- package/cli/composition.js +1 -0
- package/cli/files.js +2 -1
- package/cli/harness.js +78 -20
- package/cli/main.js +51 -15
- package/cli/maven.js +28 -0
- package/cli/portable-config.js +121 -0
- package/cli/start-config.js +15 -0
- package/cli/start.js +138 -0
- package/cli/verification-report.js +1 -1
- package/config/agents.claude-code.json +100 -0
- package/config/agents.default.json +25 -5
- package/core/agents/documentation.md +5 -0
- package/core/agents/implementer.md +5 -0
- package/core/agents/orchestrator.md +5 -0
- package/core/agents/planner.md +5 -0
- package/core/agents/project-manager.md +20 -0
- package/core/agents/reviewer.md +5 -0
- package/core/agents/security-reviewer.md +5 -0
- package/core/agents/test-engineer.md +5 -0
- package/core/checks/verification.json +12 -0
- package/core/evals/cases.json +44 -0
- package/core/policies/permissions.md +3 -1
- package/core/workflows/agent-routing.md +27 -26
- package/core/workflows/continuous-development.md +3 -0
- package/core/workflows/project-setup.md +18 -0
- package/core/workflows/spec-to-code.md +1 -1
- package/docs/README.md +6 -1
- package/docs/agent-configuration.md +9 -5
- package/docs/agent-first.md +66 -0
- package/docs/agent-handoffs.md +4 -0
- package/docs/architecture.md +14 -0
- package/docs/cli.md +21 -0
- package/docs/configuration-backup.md +122 -0
- package/docs/continuous-development.md +4 -0
- package/docs/examples/astro-micro-tools.md +1 -1
- package/docs/examples/checkpoint.json +20 -0
- package/docs/examples/spring-boot-api.md +2 -4
- package/docs/getting-started.md +9 -6
- package/docs/i18n/README.es.md +35 -0
- package/docs/i18n/README.vi.md +21 -43
- package/docs/i18n/README.zh-CN.md +35 -0
- package/docs/project-configuration.md +9 -1
- package/docs/skill-matrix.md +7 -7
- package/docs/start.md +38 -0
- package/docs/tool-setup/README.md +69 -67
- package/docs/tool-setup/claude-code.md +41 -49
- package/docs/tool-setup/codex.md +31 -43
- package/docs/tool-setup/copilot.md +35 -38
- package/docs/tool-setup/cursor.md +25 -40
- package/docs/tool-setup/gemini-cli.md +31 -35
- package/docs/tool-setup/kiro.md +55 -0
- package/docs/versioning.md +24 -0
- package/evals/real-task-comparison.md +4 -0
- package/harness.yaml +4 -2
- package/package.json +3 -2
- package/scripts/compare-tasks.js +15 -0
- package/templates/AGENTS.md +20 -28
- package/templates/delivery-plan.md +25 -0
- package/adapters/astro/skills/harness-astro-cloudflare/SKILL.md +0 -27
- package/adapters/astro/skills/harness-astro-microfrontend/SKILL.md +0 -27
- package/adapters/java-spring/skills/harness-java-spring-database-migration/SKILL.md +0 -23
- package/adapters/java-spring/skills/harness-java-spring-kafka-outbox/SKILL.md +0 -23
package/cli/harness.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { loadStartConfig } from './start-config.js';
|
|
3
|
+
import { checkpointPath, validateCheckpoint } from './checkpoint.js';
|
|
4
|
+
import { readConfig } from './project-config.js';
|
|
5
|
+
import { claudeReport, claudeBlock, linkClaude } from './claude.js';
|
|
2
6
|
import { emptyEnvironment, environmentReport } from './environment.js';
|
|
3
7
|
import { AGENT_CONFIG, agentDefaults, loadAgentConfig } from './agents.js';
|
|
4
8
|
import { reviewVerification, techspecEvidence } from './verification-report.js';
|
|
@@ -11,7 +15,7 @@ import { resolveModules } from './composition.js';
|
|
|
11
15
|
import { safe, readJson, manifest, encode, digest, stat, block, mergeAgents, payload, validateAdapters, validateCapabilities, assertSeparate, HarnessError, LOCK, CONFIG, VERSION, SLUG, isObject } from './files.js';
|
|
12
16
|
export * from './files.js';
|
|
13
17
|
|
|
14
|
-
function projectConfig(root, adapters, capabilities, required = false) {
|
|
18
|
+
export function projectConfig(root, adapters, capabilities, required = false) {
|
|
15
19
|
const filename = safe(root, CONFIG);
|
|
16
20
|
if (required && !stat(filename)) throw new HarnessError(`Missing ${CONFIG}; restore it from Git`);
|
|
17
21
|
const config = stat(filename) ? readJson(filename) : { schema_version: 1, adapters: adapters ?? [], capabilities: capabilities ?? [] };
|
|
@@ -55,7 +59,7 @@ export function bundle(source, config) {
|
|
|
55
59
|
const unknownCapabilities = capabilities.filter(c => !(m.capabilities ?? []).includes(c));
|
|
56
60
|
if (unknownCapabilities.length) throw new HarnessError(`Unknown capabilities: ${unknownCapabilities.join(', ')}`);
|
|
57
61
|
const files = {};
|
|
58
|
-
for (const folder of ['core', 'integrations', 'templates', ...adapters.map(a => `adapters/${a}`), ...capabilities.map(c => `capabilities/${c}`)]) {
|
|
62
|
+
for (const folder of ['core', 'integrations', 'templates', ...adapters.map(a => `adapters/${a}`), ...capabilities.map(c => `capabilities/${c}`), ...[...new Set(capabilities.map(c => loadCapability(source, c).asset_owner).filter(Boolean))].map(c => `capabilities/${c}`)]) {
|
|
59
63
|
for (const [name, data] of Object.entries(collect(source, folder))) {
|
|
60
64
|
files[`.harness/${name}`] = data;
|
|
61
65
|
if (name.startsWith('core/skills/')) files[`.agents/skills/${name.slice('core/skills/'.length)}`] = data;
|
|
@@ -74,11 +78,12 @@ export function bundle(source, config) {
|
|
|
74
78
|
const info = loadCapability(source, c);
|
|
75
79
|
for (const name of info.skills) {
|
|
76
80
|
const dest = `.agents/skills/${name}/SKILL.md`;
|
|
77
|
-
|
|
78
|
-
files[dest]
|
|
81
|
+
const data = fs.readFileSync(safe(source, `capabilities/${info.asset_owner ?? c}/skills/${name}/SKILL.md`));
|
|
82
|
+
if (Object.hasOwn(files, dest) && !files[dest].equals(data)) throw new HarnessError(`Skill collision: ${name}`);
|
|
83
|
+
files[dest] = data;
|
|
79
84
|
}
|
|
80
85
|
}
|
|
81
|
-
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')
|
|
86
|
+
const capabilityLinks = capabilities.map(c => `- For ${c} tasks, read \`.harness/capabilities/${loadCapability(source,c).asset_owner ?? c}/workflows/\` and discover relevant \`.agents/skills/harness-${loadCapability(source,c).asset_owner ?? c}-*/SKILL.md\` by description.`).join('\n')
|
|
82
87
|
|| '- No optional capability selected.';
|
|
83
88
|
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.';
|
|
84
89
|
const template = fs.readFileSync(safe(source, 'templates/AGENTS.md'), 'utf8');
|
|
@@ -90,7 +95,7 @@ export function bundle(source, config) {
|
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
function managedName(name) {
|
|
93
|
-
return name === 'AGENTS.md' || ['.harness/core/', '.harness/adapters/', '.harness/capabilities/', '.harness/integrations/', '.harness/templates/', '.agents/skills/harness-'].some(prefix => name.startsWith(prefix));
|
|
98
|
+
return name === 'AGENTS.md' || name === 'CLAUDE.md' || ['.harness/core/', '.harness/adapters/', '.harness/capabilities/', '.harness/integrations/', '.harness/templates/', '.agents/skills/harness-', '.claude/skills/harness-'].some(prefix => name.startsWith(prefix));
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
export function loadLock(root) {
|
|
@@ -116,7 +121,7 @@ function drift(root, lock) {
|
|
|
116
121
|
}).map(([name]) => name);
|
|
117
122
|
}
|
|
118
123
|
|
|
119
|
-
function guard(root, operation) {
|
|
124
|
+
export function guard(root, operation) {
|
|
120
125
|
const filename = safe(root, '.harness-install.lock');
|
|
121
126
|
let fd;
|
|
122
127
|
try { fd = fs.openSync(filename, 'wx', 0o600); }
|
|
@@ -128,12 +133,12 @@ function guard(root, operation) {
|
|
|
128
133
|
finally { fs.closeSync(fd); fs.unlinkSync(filename); }
|
|
129
134
|
}
|
|
130
135
|
|
|
131
|
-
export function atomicWrite(filename, data) {
|
|
136
|
+
export function atomicWrite(filename, data, mode = 0o644) {
|
|
132
137
|
fs.mkdirSync(path.dirname(filename), { recursive: true });
|
|
133
138
|
const tmp = path.join(path.dirname(filename), `.harness-tmp-${randomUUID()}`);
|
|
134
139
|
try {
|
|
135
140
|
fs.writeFileSync(tmp, data, { flag: 'wx', mode: 0o600 });
|
|
136
|
-
fs.chmodSync(tmp, stat(filename) ? fs.statSync(filename).mode & 0o777 :
|
|
141
|
+
fs.chmodSync(tmp, stat(filename) ? fs.statSync(filename).mode & 0o777 : mode);
|
|
137
142
|
fs.renameSync(tmp, filename);
|
|
138
143
|
} finally { fs.rmSync(tmp, { force: true }); }
|
|
139
144
|
}
|
|
@@ -161,18 +166,38 @@ export function transaction(root, changes, writer = atomicWrite) {
|
|
|
161
166
|
}
|
|
162
167
|
}
|
|
163
168
|
|
|
164
|
-
export function install(root, source, { command = 'init', adapters, capabilities, agentPreset, dryRun = false, allowMajor = false, writer } = {}) {
|
|
169
|
+
export function install(root, source, { command = 'init', adapters, capabilities, agentPreset, dryRun = false, allowMajor = false, host, linkClaude: linkClaudeOpt = false, claudeSkills = false, writer } = {}) {
|
|
165
170
|
assertSeparate(root, source);
|
|
166
171
|
return guard(root, () => {
|
|
167
172
|
const hasLock = Boolean(stat(safe(root, LOCK)));
|
|
168
173
|
if (command === 'update' && !hasLock) throw new HarnessError('Not installed; run init first');
|
|
169
174
|
const old = hasLock ? loadLock(root) : null;
|
|
170
175
|
const existingAgents = loadAgentConfig(root);
|
|
176
|
+
loadStartConfig(root);
|
|
171
177
|
loadArtifactContract(root); // Validate project-owned declarations before planning any writes.
|
|
172
178
|
if (agentPreset !== undefined && command !== 'init') throw new HarnessError('Agent preset is supported only by init');
|
|
173
179
|
if (agentPreset !== undefined && existingAgents) throw new HarnessError('agents.json is project-owned; edit it explicitly instead of applying an init preset');
|
|
174
180
|
const config = projectConfig(root, adapters, capabilities, hasLock);
|
|
175
181
|
const { files, lock, warnings } = bundle(source, config);
|
|
182
|
+
const claudeName = 'CLAUDE.md', claudePath = safe(root, claudeName);
|
|
183
|
+
const existingClaude = stat(claudePath) ? fs.readFileSync(claudePath) : Buffer.alloc(0);
|
|
184
|
+
if (existingClaude.length > 1024 * 1024) throw new HarnessError('CLAUDE.md exceeds 1 MiB');
|
|
185
|
+
let linkedClaude;
|
|
186
|
+
if (linkClaudeOpt || old?.files[claudeName]) {
|
|
187
|
+
linkedClaude = linkClaude(existingClaude);
|
|
188
|
+
const owned = claudeBlock(linkedClaude);
|
|
189
|
+
if (owned) { files[claudeName] = owned; lock.files[claudeName] = digest(owned); }
|
|
190
|
+
}
|
|
191
|
+
if (claudeSkills || Object.keys(old?.files ?? {}).some(p => p.startsWith('.claude/skills/harness-'))) {
|
|
192
|
+
for (const [name, data] of Object.entries(files).filter(([p]) => /^\.agents\/skills\/harness-[a-z0-9-]+\/SKILL\.md$/.test(p))) {
|
|
193
|
+
const id = name.split('/')[2], description = data.toString().match(/^description: (.+)$/m)?.[1] ?? 'Load canonical Harness instructions.';
|
|
194
|
+
const destination = '.claude/skills/' + id + '/SKILL.md';
|
|
195
|
+
files[destination] = Buffer.from('---\nname: ' + id + '\ndescription: ' + description + '\n---\n\nRead the complete canonical project file ' + String.fromCharCode(96) + name + String.fromCharCode(96) + ' before applying this skill. Follow its inputs, procedure, constraints and handoff; do not substitute this wrapper for the full instructions.\n');
|
|
196
|
+
lock.files[destination] = digest(files[destination]);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
lock.files = Object.fromEntries(Object.entries(lock.files).sort(([a],[b]) => a.localeCompare(b)));
|
|
200
|
+
lock.bundle_sha256 = digest(encode(lock.files));
|
|
176
201
|
if (old) {
|
|
177
202
|
const conflicts = drift(root, old);
|
|
178
203
|
if (conflicts.length) throw new HarnessError(`Managed files changed; reconcile in Git before updating: ${conflicts.join(', ')}`);
|
|
@@ -189,26 +214,26 @@ export function install(root, source, { command = 'init', adapters, capabilities
|
|
|
189
214
|
if (present === null || !present.equals(data)) {
|
|
190
215
|
const filename = safe(root, name);
|
|
191
216
|
const existing = stat(filename) ? fs.readFileSync(filename) : Buffer.alloc(0);
|
|
192
|
-
changes[name] = name === 'AGENTS.md' ? mergeAgents(existing, data) : data;
|
|
217
|
+
changes[name] = name === 'AGENTS.md' ? mergeAgents(existing, data) : name === 'CLAUDE.md' ? linkedClaude : data;
|
|
193
218
|
}
|
|
194
219
|
}
|
|
195
220
|
if (old) for (const name of Object.keys(old.files)) if (!Object.hasOwn(files, name)) changes[name] = null;
|
|
196
221
|
const seeds = {
|
|
197
|
-
[AGENT_CONFIG]: encode(agentDefaults(source, agentPreset ?? 'recommended')),
|
|
222
|
+
[AGENT_CONFIG]: encode(agentDefaults(source, agentPreset ?? 'recommended', host)),
|
|
198
223
|
[CONFIG]: encode(config),
|
|
199
224
|
[ARTIFACTS]: encode(emptyArtifacts()),
|
|
200
225
|
[INTEGRATIONS]: encode(emptyIntegrations()),
|
|
201
226
|
'.harness/environment.json': encode(emptyEnvironment()),
|
|
202
227
|
'openspec/config.yaml': fs.readFileSync(safe(source, 'templates/openspec/config.yaml')),
|
|
203
|
-
'.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'),
|
|
228
|
+
'.harness/local.md': Buffer.from('# Project overrides\n\nRecord project facts and reviewed exceptions here. Never weaken security boundaries.\n\n## Stack\nstack: unknown\nRecord actual dependency versions and approved design sources. This field does not select technology.\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'),
|
|
204
229
|
};
|
|
205
230
|
for (const [name, data] of Object.entries(seeds)) if (!stat(safe(root, name))) changes[name] = data;
|
|
206
231
|
// Project-owned ignore file: preserve all prior rules, place our anchored rule last.
|
|
207
232
|
// This does not remove an already tracked machine file from the Git index.
|
|
208
233
|
const ignoreName = '.harness/.gitignore', ignorePath = safe(root, ignoreName);
|
|
209
234
|
const ignore = stat(ignorePath) ? fs.readFileSync(ignorePath, 'utf8') : '';
|
|
210
|
-
if (ignore.trimEnd().split(/\r?\n/).
|
|
211
|
-
changes[ignoreName] = Buffer.from(ignore + (ignore && !ignore.endsWith('\n') ? '\n' : '') + '/machine.json\n');
|
|
235
|
+
if (ignore.trimEnd().split(/\r?\n/).slice(-2).join('\n') !== '/backups/\n/machine.json') {
|
|
236
|
+
changes[ignoreName] = Buffer.from(ignore + (ignore && !ignore.endsWith('\n') ? '\n' : '') + '/backups/\n/machine.json\n');
|
|
212
237
|
}
|
|
213
238
|
const newLock = encode(lock);
|
|
214
239
|
if (!hasLock || !fs.readFileSync(safe(root, LOCK)).equals(newLock)) changes[LOCK] = newLock;
|
|
@@ -222,6 +247,7 @@ export function check(root) {
|
|
|
222
247
|
const lock = loadLock(root), config = projectConfig(root, undefined, undefined, true);
|
|
223
248
|
const problems = drift(root, lock);
|
|
224
249
|
loadArtifactContract(root);
|
|
250
|
+
loadStartConfig(root);
|
|
225
251
|
loadAgentConfig(root); // Optional on legacy installs; validate user config without managing its bytes.
|
|
226
252
|
if (JSON.stringify([...config.adapters].sort()) !== JSON.stringify([...lock.adapters].sort())) problems.push(`${CONFIG}: adapter selection changed; run update`);
|
|
227
253
|
if (JSON.stringify([...(config.capabilities ?? [])].sort()) !== JSON.stringify([...(lock.capabilities ?? [])].sort())) problems.push(`${CONFIG}: capability selection changed; run update`);
|
|
@@ -257,20 +283,31 @@ export function newDoc(root, name, capability = 'product', options = {}) {
|
|
|
257
283
|
return guard(root, () => {
|
|
258
284
|
if (check(root).status !== 'ok') throw new HarnessError('Fix installation drift before creating a document');
|
|
259
285
|
const lock = loadLock(root);
|
|
260
|
-
|
|
286
|
+
const selected = lock.capabilities ?? [];
|
|
287
|
+
const profile = selected.find(id => {
|
|
288
|
+
const info = loadCapability(path.join(root, '.harness'), id);
|
|
289
|
+
return info.asset_owner === capability && info.templates.includes('templates/' + name + '.md');
|
|
290
|
+
});
|
|
291
|
+
if (!selected.includes(capability) && !profile) {
|
|
261
292
|
throw new HarnessError(capability === 'product' ? 'Product capability is not installed' : `Capability is not installed: ${capability}`);
|
|
262
293
|
}
|
|
263
|
-
const
|
|
294
|
+
const info = loadCapability(path.join(root, '.harness'), capability);
|
|
295
|
+
const owner = info.asset_owner ?? capability;
|
|
296
|
+
const sourceName = info.template_aliases?.['templates/' + name + '.md']
|
|
297
|
+
? '.harness/' + info.template_aliases['templates/' + name + '.md']
|
|
298
|
+
: `.harness/capabilities/${owner}/templates/${name}.md`;
|
|
264
299
|
if (!Object.hasOwn(lock.files, `.harness/capabilities/${capability}/capability.json`)) {
|
|
265
300
|
throw new HarnessError(`Capability metadata is not locked: ${capability}`);
|
|
266
301
|
}
|
|
267
302
|
const declared = loadCapability(path.join(root, '.harness'), capability).templates;
|
|
268
303
|
if (!declared.includes(`templates/${name}.md`)) throw new HarnessError(`Unknown document type for ${capability}: ${name}`);
|
|
269
304
|
if (!Object.hasOwn(lock.files, sourceName)) throw new HarnessError(`Document template is not locked: ${capability}/${name}`);
|
|
270
|
-
if (options.report && (
|
|
305
|
+
if (options.report && (owner !== 'product' || name !== 'techspec')) throw new HarnessError('--report is supported only for product techspec');
|
|
271
306
|
const content = options.report ? techspecEvidence(reviewVerification(root, options.report)) : fs.readFileSync(safe(root, sourceName), 'utf8');
|
|
272
307
|
const folder = capability === 'product' && ['idea', 'research'].includes(name) ? 'discovery' : capability;
|
|
273
|
-
const
|
|
308
|
+
const outputs = loadArtifactContract(root).artifacts.outputs;
|
|
309
|
+
const routeCapability = Object.values(outputs).some(a => a.kind === 'docs' && (a.capability ?? 'product') === capability) ? capability : owner;
|
|
310
|
+
const route = docRoute(root, routeCapability, name);
|
|
274
311
|
if (route?.external) return { status: 'planned', type: name, capability, destination: route.destination,
|
|
275
312
|
write_policy: route.write_policy, executed: false,
|
|
276
313
|
template: content,
|
|
@@ -294,5 +331,26 @@ export function doctor(root) {
|
|
|
294
331
|
}
|
|
295
332
|
const state = check(root), environment = environmentReport(root);
|
|
296
333
|
return { ...state, status: state.status !== 'ok' || environment.problems.length ? 'error' : 'ok',
|
|
297
|
-
tools, environment, artifacts: artifactReport(root), note: 'Tool presence only; no commands executed or compatibility claim made. Configured project tools take precedence over generic PATH hints.' };
|
|
334
|
+
tools, environment, claude: claudeReport(root), artifacts: artifactReport(root), note: 'Tool presence only; no commands executed or compatibility claim made. Configured project tools take precedence over generic PATH hints.' };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function saveCheckpoint(root, id, report, expected) {
|
|
338
|
+
return guard(root, () => {
|
|
339
|
+
if (check(root).status !== 'ok') throw new HarnessError('Fix installation drift before checkpointing');
|
|
340
|
+
const name = checkpointPath(id);
|
|
341
|
+
if (!stat(safe(root, 'openspec/changes/' + id + '/tasks.md'))) throw new HarnessError('Checkpoint requires an existing change');
|
|
342
|
+
const row = validateCheckpoint(root, id, readConfig(root, report));
|
|
343
|
+
const destination = safe(root, name), previous = stat(destination) ? fs.readFileSync(destination) : null;
|
|
344
|
+
if (previous && (!/^[a-f0-9]{64}$/.test(expected ?? '') || digest(previous) !== expected)) throw new HarnessError('Checkpoint exists or changed; resume, reconcile, then supply --expected digest');
|
|
345
|
+
if (!previous && expected !== undefined) throw new HarnessError('Expected checkpoint is missing');
|
|
346
|
+
const changes = { [name]: encode(row) };
|
|
347
|
+
if (previous) {
|
|
348
|
+
const history = 'openspec/changes/' + id + '/checkpoint-history/' + digest(previous) + '.json';
|
|
349
|
+
const p = safe(root, history);
|
|
350
|
+
if (stat(p) && !fs.readFileSync(p).equals(previous)) throw new HarnessError('Checkpoint history collision');
|
|
351
|
+
if (!stat(p)) changes[history] = previous;
|
|
352
|
+
}
|
|
353
|
+
transaction(root, changes);
|
|
354
|
+
return { status: 'ok', checkpoint: name, checkpoint_sha256: digest(encode(row)), executed: false, note: 'Saved project-owned handoff/evidence declarations. Execution and remote state still require host verification.' };
|
|
355
|
+
});
|
|
298
356
|
}
|
package/cli/main.js
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
|
+
import { startPlan, saveStart, guidedStart } from './start.js';
|
|
2
|
+
import { backupConfig, restoreConfig } from './portable-config.js';
|
|
1
3
|
import { listAdapters, detect, verify, evaluate } from './adapters.js';
|
|
2
4
|
import { listCapabilities, skillCatalog, readSkill } from './capabilities.js';
|
|
3
5
|
import { resolveModules } from './composition.js';
|
|
4
6
|
import { parseArgs } from 'node:util';
|
|
5
7
|
import { setupPlan } from './environment.js';
|
|
6
8
|
import { ready } from './readiness.js';
|
|
9
|
+
import { resumePlan } from './checkpoint.js';
|
|
10
|
+
import { agentPlan } from './agents.js';
|
|
7
11
|
import { adaptiveVerification } from './verification.js';
|
|
8
12
|
import { reviewVerification } from './verification-report.js';
|
|
9
13
|
import { artifactReport } from './artifacts.js';
|
|
10
|
-
import { SOURCE, readJson, targetRoot, install, check, doctor, newChange, newDoc, loadLock } from './harness.js';
|
|
14
|
+
import { SOURCE, readJson, targetRoot, install, check, doctor, newChange, newDoc, loadLock, saveCheckpoint } from './harness.js';
|
|
11
15
|
import path from 'node:path';
|
|
12
16
|
|
|
13
17
|
const HELP = `aih — install reusable AI development rules into a project
|
|
14
18
|
|
|
15
19
|
Usage: aih <command> [options]
|
|
16
20
|
list List available adapters and capabilities
|
|
21
|
+
checkpoint <id> Save a validated --report handoff; replace with --expected digest
|
|
22
|
+
resume <id> Inspect checkpoint freshness and side effects before continuation
|
|
23
|
+
agents Show installed agent contracts and declared model/skill routing
|
|
17
24
|
skills List installed skill names/descriptions without full content
|
|
18
25
|
skill <name> Load one installed skill on demand
|
|
19
26
|
detect Suggest adapters with evidence from target files
|
|
@@ -24,6 +31,9 @@ Usage: aih <command> [options]
|
|
|
24
31
|
update Apply the installed CLI bundle to the project (no download)
|
|
25
32
|
check Check Harness file integrity, not application tests
|
|
26
33
|
doctor Check installation, configured paths and tool presence
|
|
34
|
+
start Guide intake; reuse saved answers and hand off to your AI host
|
|
35
|
+
backup Export user configuration (not docs/checkpoints/secrets)
|
|
36
|
+
restore Restore --from backup; --transfer reuses modules/model preferences
|
|
27
37
|
setup Suggest first-run questions for the AI host (read-only)
|
|
28
38
|
artifacts Show input/output/storage and connection declarations (read-only)
|
|
29
39
|
ready <id> Check structural change readiness, not approval
|
|
@@ -31,15 +41,25 @@ Usage: aih <command> [options]
|
|
|
31
41
|
new-doc <type> Scaffold a locked capability template (default: product)
|
|
32
42
|
|
|
33
43
|
Options:
|
|
44
|
+
--goal <type> start: idea, docs, change, resume (saves with --task)
|
|
45
|
+
--task <text> start: task description; --change supplies resume checkpoint ID
|
|
46
|
+
--input <path/url> start: seed requirements only if no inputs declared
|
|
47
|
+
--output <path> start: draft docs directory; backup: archive path
|
|
48
|
+
--from <file> restore: project-relative archive file
|
|
49
|
+
--transfer restore: only modules/agent models; omit old autonomy
|
|
50
|
+
--replace restore: explicit replacement; automatic before-backup
|
|
34
51
|
--target <directory> Project directory (default: current directory)
|
|
35
52
|
--detect init only: opt in to detected stack selection
|
|
36
53
|
--stage <type> ready only: implement (default) or review
|
|
37
54
|
--capability <id> new-doc only: installed template owner (default: product)
|
|
38
55
|
--agent-preset <id> init only: recommended (default), economy, custom; no overwrite
|
|
39
|
-
--
|
|
56
|
+
--link-claude init/update: opt in to a managed CLAUDE.md import
|
|
57
|
+
--claude-skills init/update: opt in to canonical .claude/skills wrappers
|
|
58
|
+
--host <id> init/setup/agents: codex,claude-code,gemini-cli,cursor,copilot or custom host ID
|
|
40
59
|
--scope <type> verify/eval: adapters (default), capabilities, core, all
|
|
41
60
|
--adaptive verify: discover a read-only project verification plan
|
|
42
|
-
--
|
|
61
|
+
--expected <sha> checkpoint: observed prior checkpoint digest for replacement
|
|
62
|
+
--report <file> checkpoint: save handoff; eval: score; verify: validate evidence; new-doc techspec: link evidence
|
|
43
63
|
--adapters <a,b> init/resolve: java-spring,react-nextjs,react-native,ai-python,kubernetes,astro
|
|
44
64
|
--capabilities <a,b> init/resolve: uiux,product,architecture,delivery,devops,monetization
|
|
45
65
|
--source <directory> init/update/list/detect/resolve: trusted alternate bundle directory
|
|
@@ -48,7 +68,9 @@ Options:
|
|
|
48
68
|
-h, --help Show help
|
|
49
69
|
-v, --version Show installed CLI version
|
|
50
70
|
|
|
51
|
-
Start: init →
|
|
71
|
+
Start: init → start → open your AI host and send the returned host_prompt.
|
|
72
|
+
User settings: .harness/; backup exports only documented user files.
|
|
73
|
+
Restore config before init on a fresh project; --transfer excludes old sources/tasks.
|
|
52
74
|
The host runs application tests; verify prints plans or validates supplied evidence.
|
|
53
75
|
Update a global CLI with npm install -g @manhd_d/ai-harness@latest,
|
|
54
76
|
then run aih update --dry-run, aih update, and aih check in the project.
|
|
@@ -87,9 +109,9 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
87
109
|
let args;
|
|
88
110
|
try {
|
|
89
111
|
args = parseArgs({ args: argv, allowPositionals: true, strict: true, tokens: true, options: {
|
|
90
|
-
target: { type: 'string' }, adapters: { type: 'string' }, capabilities: { type: 'string' }, capability: { type: 'string' }, source: { type: 'string' },
|
|
91
|
-
'agent-preset': { type: 'string' }, host: { type: 'string' },
|
|
92
|
-
adaptive: { type: 'boolean' }, detect: { type: 'boolean' }, report: { type: 'string' }, scope: { type: 'string' }, stage: { type: 'string' },
|
|
112
|
+
goal: { type: 'string' }, task: { type: 'string' }, change: { type: 'string' }, input: { type: 'string' }, output: { type: 'string' }, from: { type: 'string' }, transfer: { type: 'boolean' }, replace: { type: 'boolean' }, target: { type: 'string' }, adapters: { type: 'string' }, capabilities: { type: 'string' }, capability: { type: 'string' }, source: { type: 'string' },
|
|
113
|
+
'agent-preset': { type: 'string' }, host: { type: 'string' }, expected: { type: 'string' },
|
|
114
|
+
'link-claude': { type: 'boolean' }, 'claude-skills': { type: 'boolean' }, adaptive: { type: 'boolean' }, detect: { type: 'boolean' }, report: { type: 'string' }, scope: { type: 'string' }, stage: { type: 'string' },
|
|
93
115
|
'dry-run': { type: 'boolean' }, 'allow-major': { type: 'boolean' },
|
|
94
116
|
help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
|
|
95
117
|
} });
|
|
@@ -101,30 +123,41 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
101
123
|
if (args.values.help || !argv.length) { console.log(HELP); return 0; }
|
|
102
124
|
if (args.values.version) { console.log(readJson(path.join(SOURCE, 'package.json')).version); return 0; }
|
|
103
125
|
const [command, name, ...extra] = args.positionals;
|
|
104
|
-
if (!['init', 'update', 'check', 'doctor', 'new-change', 'new-doc', 'list', 'detect', 'resolve', 'verify', 'eval', 'skills', 'skill', 'setup', 'ready', 'artifacts'].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
105
|
-
if (extra.length || (!['new-change', 'new-doc', 'skill', 'ready'].includes(command) && name !== undefined) || (['new-change', 'new-doc', 'skill', 'ready'].includes(command) && name === undefined)) throw new Error('Unexpected or missing positional argument; run aih --help');
|
|
126
|
+
if (!['start', 'backup', 'restore', 'init', 'update', 'check', 'doctor', 'new-change', 'new-doc', 'list', 'detect', 'resolve', 'verify', 'eval', 'skills', 'skill', 'setup', 'ready', 'artifacts', 'agents', 'checkpoint', 'resume'].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
127
|
+
if (extra.length || (!['new-change', 'new-doc', 'skill', 'ready', 'checkpoint', 'resume'].includes(command) && name !== undefined) || (['new-change', 'new-doc', 'skill', 'ready', 'checkpoint', 'resume'].includes(command) && name === undefined)) throw new Error('Unexpected or missing positional argument; run aih --help');
|
|
106
128
|
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'] : []), ...(command === 'ready' ? ['stage'] : []), ...(['verify', 'eval'].includes(command) ? ['scope'] : [])]);
|
|
129
|
+
if (command === 'start') for (const key of ['goal','task','change','input','output','host']) allowed.add(key);
|
|
130
|
+
if (command === 'backup') allowed.add('output');
|
|
131
|
+
if (command === 'restore') { for (const key of ['from','transfer','replace','dry-run']) allowed.add(key); if (!args.values.from) throw new Error('restore requires --from'); }
|
|
132
|
+
if (command === 'start' && !args.values.goal && ['task','change','input','output'].some(key => args.values[key] !== undefined)) throw new Error('start saving options require --goal');
|
|
133
|
+
if (command === 'checkpoint') { allowed.add('report'); allowed.add('expected'); if (!args.values.report) throw new Error('checkpoint requires --report'); }
|
|
107
134
|
if (command === 'new-doc') { allowed.add('capability'); allowed.add('report'); }
|
|
108
135
|
if (command === 'verify') { allowed.add('adaptive'); allowed.add('report'); }
|
|
109
136
|
if (args.values.adaptive && args.values.report) throw new Error('Choose --adaptive or --report');
|
|
110
137
|
if (args.values.scope && (args.values.adaptive || (command === 'verify' && args.values.report))) throw new Error('--scope applies only to advisory checks');
|
|
111
138
|
if (command === 'init') allowed.add('agent-preset');
|
|
112
|
-
if (
|
|
139
|
+
if (['setup','agents','init'].includes(command)) allowed.add('host');
|
|
140
|
+
if (['init','update'].includes(command)) { allowed.add('link-claude'); allowed.add('claude-skills'); }
|
|
113
141
|
for (const key of Object.keys(args.values)) if (!allowed.has(key)) throw new Error(`--${key} is not supported for ${command}`);
|
|
114
142
|
if (args.values.detect && args.values.adapters !== undefined) throw new Error('Choose either --detect or --adapters');
|
|
115
|
-
for (const key of ['target', 'source', 'adapters', 'capabilities', 'capability', 'report', 'agent-preset', 'host']) if (args.values[key] !== undefined && !args.values[key].trim()) throw new Error(`--${key} cannot be empty`);
|
|
143
|
+
for (const key of ['goal', 'task', 'change', 'input', 'output', 'from', 'target', 'source', 'adapters', 'capabilities', 'capability', 'report', 'agent-preset', 'host']) if (args.values[key] !== undefined && !args.values[key].trim()) throw new Error(`--${key} cannot be empty`);
|
|
116
144
|
} catch (error) { console.error(JSON.stringify({ status: 'error', message: error.message })); return 2; }
|
|
117
145
|
try {
|
|
118
146
|
const { values, positionals: [command, name] } = args;
|
|
119
147
|
const root = targetRoot(values.target ?? process.cwd());
|
|
120
148
|
let result;
|
|
121
|
-
if (command === '
|
|
149
|
+
if (command === 'start') {
|
|
150
|
+
if (!values.goal && process.stdin.isTTY && process.stdout.isTTY) return guidedStart(root, { host: values.host }).then(value => { console.log(JSON.stringify(value, null, 2)); return value.status === 'blocked' ? 1 : 0; }).catch(error => { console.error(JSON.stringify({ status: 'error', message: error.message })); return 1; });
|
|
151
|
+
result = values.goal ? saveStart(root, { goal: values.goal, task: values.task, changeId: values.change, input: values.input, output: values.output, host: values.host }) : startPlan(root, values.host);
|
|
152
|
+
} else if (command === 'backup') result = backupConfig(root, values.output);
|
|
153
|
+
else if (command === 'restore') result = restoreConfig(root, values.from, { dryRun: values['dry-run'], transfer: values.transfer, replace: values.replace });
|
|
154
|
+
else if (command === 'init' || command === 'update') {
|
|
122
155
|
const source = targetRoot(values.source ?? SOURCE);
|
|
123
156
|
const selection = values.detect ? detect(root, source).adapters : undefined;
|
|
124
157
|
if (values.detect && !selection.length) throw new Error('No stack detected; choose --adapters explicitly or omit --detect for core only');
|
|
125
158
|
result = install(root, source, {
|
|
126
159
|
command, adapters: selection ?? (values.adapters === undefined ? undefined : values.adapters.split(',').map(v => v.trim())),
|
|
127
|
-
agentPreset: values['agent-preset'],
|
|
160
|
+
agentPreset: values['agent-preset'], host: values.host, linkClaude: values['link-claude'], claudeSkills: values['claude-skills'],
|
|
128
161
|
capabilities: values.capabilities === undefined ? undefined : values.capabilities.split(',').map(v => v.trim()),
|
|
129
162
|
dryRun: values['dry-run'], allowMajor: values['allow-major'],
|
|
130
163
|
});
|
|
@@ -145,6 +178,9 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
145
178
|
result = values.report ? reviewVerification(root, values.report) : values.adaptive ? adaptiveVerification(root, SOURCE) : verify(root, values.scope ?? 'adapters');
|
|
146
179
|
}
|
|
147
180
|
else if (command === 'eval') result = evaluate(root, values.report, values.scope ?? 'adapters');
|
|
181
|
+
else if (command === 'checkpoint') result = saveCheckpoint(root, name, values.report, values.expected);
|
|
182
|
+
else if (command === 'resume') { if (check(root).status !== 'ok') throw new Error('Fix installation drift before resume'); result = resumePlan(root, name); }
|
|
183
|
+
else if (command === 'agents') { if (check(root).status !== 'ok') throw new Error('Fix installation drift before reading agents'); result = agentPlan(root, values.host); }
|
|
148
184
|
else if (command === 'artifacts') {
|
|
149
185
|
if (check(root).status !== 'ok') throw new Error('Fix installation drift before reading artifact declarations');
|
|
150
186
|
result = artifactReport(root);
|
|
@@ -159,9 +195,9 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
159
195
|
else result = command === 'doctor' ? doctor(root) : check(root);
|
|
160
196
|
if (command === 'init' || command === 'update') result.next_steps = values['dry-run']
|
|
161
197
|
? ['Review the proposed changes, then repeat without --dry-run.']
|
|
162
|
-
: ['Run aih
|
|
198
|
+
: ['Run start with the same CLI (aih start or npx --yes @manhd_d/ai-harness start); open your AI host and send host_prompt.',
|
|
163
199
|
'Review local.md/environment.json and artifacts.json/integrations.json; record input/output/storage/ownership, keep credentials in the host and personal paths in ignored machine.json.',
|
|
164
|
-
'Run
|
|
200
|
+
'Run doctor and review/commit shared configuration and snapshot. Use backup; copy its file outside the repo. See docs/configuration-backup.md.'];
|
|
165
201
|
if (command === 'new-change') result.next_steps = [
|
|
166
202
|
'Resolve consequential ambiguity with the user and record answers/sources in decisions.json.',
|
|
167
203
|
'When artifact outputs are configured, record create/update/unchanged impact and source-write policy in artifact-impact.json.',
|
package/cli/maven.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { safe, stat, HarnessError } from './files.js';
|
|
4
|
+
|
|
5
|
+
// A bounded metadata reader, not an XML runtime. No entities, interpolation or parent traversal.
|
|
6
|
+
export function mavenModules(root) {
|
|
7
|
+
const files=[],warnings=[],visited=new Set();let total=0;
|
|
8
|
+
function visit(file,depth){
|
|
9
|
+
if(visited.has(file))return;visited.add(file);
|
|
10
|
+
if(visited.size>64)throw new HarnessError('Maven discovery exceeds 64 POM files');
|
|
11
|
+
const p=safe(root,file),info=stat(p);if(!info){if(depth>0)warnings.push({file,reason:'Declared POM is missing'});return;}
|
|
12
|
+
if(info.size>1024*1024)throw new HarnessError('Maven POM exceeds 1 MiB: '+file);
|
|
13
|
+
total+=info.size;if(total>4*1024*1024)throw new HarnessError('Maven discovery exceeds 4 MiB total');
|
|
14
|
+
const body=fs.readFileSync(p,'utf8').replace(/<!--[\s\S]*?-->/g,'');
|
|
15
|
+
if(/<!DOCTYPE|<!ENTITY|<!\[CDATA\[/i.test(body))throw new HarnessError('Unsupported XML declarations in '+file);
|
|
16
|
+
files.push({file,body,depth});
|
|
17
|
+
if(depth>=2){if(/<(?:[\w.-]+:)?modules\b/.test(body))warnings.push({file,reason:'Depth limit reached; deeper modules not scanned'});return;}
|
|
18
|
+
for(const section of body.matchAll(/<(?:[\w.-]+:)?modules\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?modules\s*>/g)){
|
|
19
|
+
for(const item of section[1].matchAll(/<(?:[\w.-]+:)?module\b[^>]*>([^<]*)<\/(?:[\w.-]+:)?module\s*>/g)){
|
|
20
|
+
const value=item[1].trim();
|
|
21
|
+
if(!value || /[$&\\]/.test(value) || value.split('/').some(x=>x==='..'||x==='.') || value.startsWith('/'))
|
|
22
|
+
throw new HarnessError('Unsupported or unsafe Maven module path in '+file);
|
|
23
|
+
const child=path.posix.join(path.posix.dirname(file),value,'pom.xml');safe(root,child);visit(child,depth+1);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
visit('pom.xml',0);return {files,warnings};
|
|
28
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { safe, stat, encode, digest, isObject, SOURCE, VERSION, HarnessError } from './files.js';
|
|
6
|
+
import { projectConfig, guard, transaction, atomicWrite } from './harness.js';
|
|
7
|
+
import { loadEnvironment } from './environment.js';
|
|
8
|
+
import { loadAgentConfig } from './agents.js';
|
|
9
|
+
import { loadArtifactContract } from './artifacts.js';
|
|
10
|
+
import { loadStartConfig } from './start-config.js';
|
|
11
|
+
import { resolveModules } from './composition.js';
|
|
12
|
+
|
|
13
|
+
export const USER_FILES = ['.harness/project.json', '.harness/agents.json', '.harness/artifacts.json',
|
|
14
|
+
'.harness/integrations.json', '.harness/environment.json', '.harness/local.md', '.harness/start.json', 'openspec/config.yaml'];
|
|
15
|
+
const MAX_FILE = 1024 * 1024, MAX_ARCHIVE = 12 * MAX_FILE;
|
|
16
|
+
const fail = (ok, message) => { if (!ok) throw new HarnessError(message); };
|
|
17
|
+
export const backupName = () => '.harness/backups/config-' + new Date().toISOString().replace(/[:.]/g, '-') + '-' + randomUUID() + '.json';
|
|
18
|
+
function readText(root, name) {
|
|
19
|
+
const filename = safe(root, name), info = stat(filename);
|
|
20
|
+
if (!info) return null;
|
|
21
|
+
fail(info.size <= MAX_FILE, 'Configuration exceeds 1 MiB: ' + name);
|
|
22
|
+
const bytes = fs.readFileSync(filename), content = bytes.toString('utf8');
|
|
23
|
+
fail(Buffer.from(content).equals(bytes), 'Configuration must be UTF-8: ' + name);
|
|
24
|
+
return content;
|
|
25
|
+
}
|
|
26
|
+
export function configArchive(root) {
|
|
27
|
+
const files = {};
|
|
28
|
+
for (const name of USER_FILES) {
|
|
29
|
+
const content = readText(root, name);
|
|
30
|
+
if (content !== null) files[name] = { content, sha256: digest(Buffer.from(content)) };
|
|
31
|
+
}
|
|
32
|
+
fail(Object.hasOwn(files, '.harness/project.json'), 'Missing .harness/project.json; initialize or restore project configuration first');
|
|
33
|
+
return { schema_version: 1, kind: 'ai-harness-user-config', cli_version: JSON.parse(fs.readFileSync(path.join(SOURCE, 'package.json'))).version,
|
|
34
|
+
files };
|
|
35
|
+
}
|
|
36
|
+
function validateArchive(value) {
|
|
37
|
+
fail(isObject(value) && Object.keys(value).sort().join(',') === 'cli_version,files,kind,schema_version'
|
|
38
|
+
&& value.schema_version === 1 && value.kind === 'ai-harness-user-config' && VERSION.test(value.cli_version)
|
|
39
|
+
&& isObject(value.files) && Object.hasOwn(value.files, '.harness/project.json'), 'Unsupported configuration backup schema');
|
|
40
|
+
for (const [name, file] of Object.entries(value.files)) {
|
|
41
|
+
fail(USER_FILES.includes(name), 'Backup contains an unapproved configuration path');
|
|
42
|
+
fail(isObject(file) && Object.keys(file).sort().join(',') === 'content,sha256' && typeof file.content === 'string'
|
|
43
|
+
&& Buffer.byteLength(file.content) <= MAX_FILE && /^[a-f0-9]{64}$/.test(file.sha256)
|
|
44
|
+
&& digest(Buffer.from(file.content)) === file.sha256, 'Backup content/digest mismatch: ' + name);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function validateFiles(files) {
|
|
48
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'aih-config-'));
|
|
49
|
+
try {
|
|
50
|
+
for (const [name, file] of Object.entries(files)) {
|
|
51
|
+
if (name.endsWith('.json')) { try { JSON.parse(file.content); } catch { throw new HarnessError('Invalid JSON configuration: ' + name); } }
|
|
52
|
+
const dest = safe(temp, name); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, file.content, { mode: 0o600 });
|
|
53
|
+
}
|
|
54
|
+
const config = projectConfig(temp, undefined, undefined, true);
|
|
55
|
+
resolveModules(SOURCE, config);
|
|
56
|
+
loadAgentConfig(temp); loadArtifactContract(temp); loadEnvironment(temp); loadStartConfig(temp);
|
|
57
|
+
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
|
58
|
+
}
|
|
59
|
+
function outputPath(root, name) {
|
|
60
|
+
fail(typeof name === 'string' && name.endsWith('.json') && !/^(?:\.git|\.agents|\.claude|node_modules)(?:\/|$)/.test(name) && !USER_FILES.includes(name) && !name.startsWith('.harness/core/') && !name.startsWith('openspec/changes/')
|
|
61
|
+
&& (!name.startsWith('.harness/') || name.startsWith('.harness/backups/')), 'Use a backup path outside active Harness configuration');
|
|
62
|
+
const filename = safe(root, name); fail(!stat(filename), 'Backup destination already exists'); return filename;
|
|
63
|
+
}
|
|
64
|
+
export function backupConfig(root, output = backupName()) {
|
|
65
|
+
return guard(root, () => {
|
|
66
|
+
const archive = configArchive(root); validateArchive(archive); validateFiles(archive.files);
|
|
67
|
+
const filename = outputPath(root, output);
|
|
68
|
+
fs.mkdirSync(path.dirname(filename), { recursive: true });
|
|
69
|
+
fs.writeFileSync(filename, encode(archive), { flag: 'wx', mode: 0o600 });
|
|
70
|
+
return { status: 'ok', backup: output, included: Object.keys(archive.files),
|
|
71
|
+
excluded: ['machine.json', 'credentials and native host settings', 'managed snapshot and lock', 'custom skills', 'source/output document contents', 'OpenSpec changes, checkpoints and evidence'],
|
|
72
|
+
next_steps: ['Copy the backup to a private location outside this project.', 'Keep code/docs/openspec changes and custom skills in your repository backup; configuration backup is not a full project backup.'] };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export function restoreConfig(root, from, { dryRun = false, transfer = false, replace = false, writer } = {}) {
|
|
76
|
+
const filename = safe(root, from); fail(stat(filename)?.size <= MAX_ARCHIVE, 'Missing or oversized configuration backup');
|
|
77
|
+
const archive = readConfigArchive(filename); validateArchive(archive); validateFiles(archive.files);
|
|
78
|
+
return guard(root, () => {
|
|
79
|
+
const files = transfer ? Object.fromEntries(Object.entries(archive.files).filter(([name]) => ['.harness/project.json', '.harness/agents.json'].includes(name))) : archive.files;
|
|
80
|
+
if (transfer && files['.harness/agents.json']) {
|
|
81
|
+
const config = JSON.parse(files['.harness/agents.json'].content);
|
|
82
|
+
delete config.execution.autonomy; // Repository-scoped autonomy decisions never move to a different project.
|
|
83
|
+
files['.harness/agents.json'] = { content: encode(config).toString() };
|
|
84
|
+
}
|
|
85
|
+
const changes = {}, conflicts = [];
|
|
86
|
+
for (const [name, file] of Object.entries(files)) {
|
|
87
|
+
const current = readText(root, name);
|
|
88
|
+
if (current === file.content) continue;
|
|
89
|
+
if (current !== null) conflicts.push(name);
|
|
90
|
+
changes[name] = Buffer.from(file.content);
|
|
91
|
+
}
|
|
92
|
+
fail(!conflicts.length || replace || dryRun, 'Existing configuration differs: ' + conflicts.join(', ') + '; review --dry-run --replace before replacement');
|
|
93
|
+
// Validate the prospective merged configuration before any target writes.
|
|
94
|
+
const merged = {};
|
|
95
|
+
for (const name of USER_FILES) {
|
|
96
|
+
const content = changes[name]?.toString() ?? readText(root, name);
|
|
97
|
+
if (content !== null) merged[name] = { content };
|
|
98
|
+
}
|
|
99
|
+
validateFiles(merged);
|
|
100
|
+
// Validate declared artifact paths against actual target symlinks, not only temporary files.
|
|
101
|
+
const artifacts = merged['.harness/artifacts.json'] ? JSON.parse(merged['.harness/artifacts.json'].content) : null;
|
|
102
|
+
for (const group of ['inputs','outputs']) for (const item of Object.values(artifacts?.[group] ?? {})) {
|
|
103
|
+
if (item.location.path) safe(root, item.location.path + (group === 'outputs' || stat(path.join(root, item.location.path))?.isDirectory() ? '/.artifact-probe' : ''));
|
|
104
|
+
}
|
|
105
|
+
let backup = null;
|
|
106
|
+
if (!dryRun && conflicts.length) {
|
|
107
|
+
const before = configArchive(root); backup = backupName(); outputPath(root, backup);
|
|
108
|
+
changes[backup] = encode(before);
|
|
109
|
+
}
|
|
110
|
+
if (!dryRun) transaction(root, changes, writer ?? ((filename, data) => atomicWrite(filename, data, filename === (backup ? safe(root, backup) : null) ? 0o600 : 0o644)));
|
|
111
|
+
return { status: dryRun ? 'planned' : 'ok', mode: transfer ? 'transfer' : 'restore', changes: Object.keys(changes), conflicts,
|
|
112
|
+
backup, omitted: Object.keys(archive.files).filter(name => !Object.hasOwn(files, name)),
|
|
113
|
+
next_steps: ['Run init on a fresh project, or update --dry-run then update on an installed project; run check.',
|
|
114
|
+
transfer ? 'Run start for a new task; rebind input/output locations, commands and connections. Old autonomy declarations were removed.' : 'Run doctor and start; restore docs/checkpoints/evidence separately from your project backup before resume.',
|
|
115
|
+
'Review host access, models, sources and write policies; importing declarations does not grant permission or connect accounts.'] };
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
function readConfigArchive(filename) {
|
|
119
|
+
try { return JSON.parse(fs.readFileSync(filename, 'utf8')); }
|
|
120
|
+
catch { throw new HarnessError('Invalid configuration backup JSON'); }
|
|
121
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readConfig } from './project-config.js';
|
|
2
|
+
import { isObject, SLUG, HarnessError, safe, stat } from './files.js';
|
|
3
|
+
export const START_CONFIG = '.harness/start.json';
|
|
4
|
+
export const GOALS = ['idea', 'docs', 'change', 'resume'];
|
|
5
|
+
export function loadStartConfig(root) {
|
|
6
|
+
const value = readConfig(root, START_CONFIG);
|
|
7
|
+
if (value === null) { if (stat(safe(root, START_CONFIG))) throw new HarnessError('Invalid start.json schema'); return null; }
|
|
8
|
+
const text = s => typeof s === 'string' && s.trim().length > 0 && s.length <= 4096 && !/[\x00-\x1f\x7f]/.test(s);
|
|
9
|
+
if (!isObject(value) || Object.keys(value).some(k => !['schema_version', 'goal', 'task', 'change_id'].includes(k))
|
|
10
|
+
|| value.schema_version !== 1 || !GOALS.includes(value.goal) || !text(value.task)
|
|
11
|
+
|| (value.goal === 'resume' ? !SLUG.test(value.change_id ?? '') || value.change_id === 'archive' : value.change_id !== undefined)) {
|
|
12
|
+
throw new HarnessError('Invalid start.json schema/goal/task/change_id');
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|