@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.
Files changed (144) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +28 -37
  3. package/adapters/ai-python/adapter.yaml +1 -1
  4. package/adapters/ai-python/rules.md +3 -0
  5. package/adapters/ai-python/skills/harness-ai-python-rag-access/SKILL.md +1 -0
  6. package/adapters/ai-python/skills/harness-ai-python-tool-safety/SKILL.md +1 -0
  7. package/adapters/astro/adapter.yaml +3 -5
  8. package/adapters/astro/checks/verification.yaml +6 -6
  9. package/adapters/astro/evals/cases.json +5 -5
  10. package/adapters/astro/rules/architecture.md +2 -8
  11. package/adapters/astro/rules/deployment.md +2 -8
  12. package/adapters/astro/rules.md +5 -14
  13. package/adapters/astro/skills/harness-astro-islands/SKILL.md +3 -2
  14. package/adapters/astro/skills/harness-astro-performance/SKILL.md +1 -0
  15. package/adapters/astro/skills/harness-astro-project-architecture/SKILL.md +9 -19
  16. package/adapters/astro/skills/harness-astro-seo/SKILL.md +1 -0
  17. package/adapters/java-spring/adapter.yaml +2 -4
  18. package/adapters/java-spring/rules/database.md +2 -2
  19. package/adapters/java-spring/rules/performance.md +2 -2
  20. package/adapters/java-spring/rules.md +4 -1
  21. package/adapters/java-spring/skills/harness-java-spring-spring-api/SKILL.md +1 -0
  22. package/adapters/kubernetes/adapter.yaml +1 -1
  23. package/adapters/kubernetes/rules.md +3 -0
  24. package/adapters/kubernetes/skills/harness-kubernetes-helm-release/SKILL.md +1 -0
  25. package/adapters/kubernetes/skills/harness-kubernetes-rollout-safety/SKILL.md +1 -0
  26. package/adapters/kubernetes/skills/harness-kubernetes-workload-review/SKILL.md +1 -0
  27. package/adapters/react-native/adapter.yaml +1 -1
  28. package/adapters/react-native/rules.md +3 -0
  29. package/adapters/react-native/skills/harness-react-native-behavior-testing/SKILL.md +1 -0
  30. package/adapters/react-native/skills/harness-react-native-device-permissions/SKILL.md +1 -0
  31. package/adapters/react-native/skills/harness-react-native-offline-sync/SKILL.md +1 -0
  32. package/adapters/react-native/skills/harness-react-native-screen-delivery/SKILL.md +1 -0
  33. package/adapters/react-nextjs/adapter.yaml +1 -1
  34. package/adapters/react-nextjs/rules.md +3 -0
  35. package/adapters/react-nextjs/skills/harness-react-nextjs-accessible-ui/SKILL.md +1 -0
  36. package/adapters/react-nextjs/skills/harness-react-nextjs-behavior-testing/SKILL.md +1 -0
  37. package/adapters/react-nextjs/skills/harness-react-nextjs-feature-delivery/SKILL.md +1 -0
  38. package/adapters/react-nextjs/skills/harness-react-nextjs-server-boundaries/SKILL.md +1 -0
  39. package/bin/aih.js +1 -1
  40. package/capabilities/analysis/capability.json +48 -0
  41. package/capabilities/analysis/checks/verification.json +16 -0
  42. package/capabilities/analysis/evals/cases.json +15 -0
  43. package/capabilities/architecture/agents/architecture-reviewer.md +5 -0
  44. package/capabilities/architecture/agents/solution-architect.md +5 -0
  45. package/capabilities/architecture/capability.json +1 -1
  46. package/capabilities/delivery/agents/project-manager.md +10 -11
  47. package/capabilities/delivery/capability.json +8 -2
  48. package/capabilities/delivery/templates/delivery-plan.md +2 -23
  49. package/capabilities/discovery/capability.json +37 -0
  50. package/capabilities/discovery/checks/verification.json +16 -0
  51. package/capabilities/discovery/evals/cases.json +15 -0
  52. package/capabilities/product/agents/business-analyst.md +5 -0
  53. package/capabilities/product/agents/product-manager.md +5 -0
  54. package/capabilities/product/agents/product-owner.md +5 -0
  55. package/capabilities/product/agents/project-manager.md +9 -14
  56. package/capabilities/product/agents/requirements-reviewer.md +5 -0
  57. package/capabilities/product/capability.json +8 -2
  58. package/capabilities/product/common/document-contract.md +10 -0
  59. package/capabilities/product/evals/cases.json +11 -0
  60. package/capabilities/product/skills/harness-product-acceptance-criteria/SKILL.md +4 -7
  61. package/capabilities/product/skills/harness-product-backlog-prioritization/SKILL.md +2 -6
  62. package/capabilities/product/skills/harness-product-brd-generation/SKILL.md +2 -6
  63. package/capabilities/product/skills/harness-product-business-analysis/SKILL.md +7 -10
  64. package/capabilities/product/skills/harness-product-document-review/SKILL.md +2 -6
  65. package/capabilities/product/skills/harness-product-idea-discovery/SKILL.md +2 -6
  66. package/capabilities/product/skills/harness-product-knowledge-analysis/SKILL.md +2 -6
  67. package/capabilities/product/skills/harness-product-mvp-definition/SKILL.md +1 -2
  68. package/capabilities/product/skills/harness-product-prd-generation/SKILL.md +2 -6
  69. package/capabilities/product/skills/harness-product-product-outcomes/SKILL.md +1 -2
  70. package/capabilities/product/skills/harness-product-product-research/SKILL.md +2 -6
  71. package/capabilities/product/skills/harness-product-srs-generation/SKILL.md +2 -6
  72. package/capabilities/product/skills/harness-product-techspec/SKILL.md +1 -1
  73. package/capabilities/product/skills/harness-product-traceability/SKILL.md +2 -6
  74. package/capabilities/product/skills/harness-product-use-case-generation/SKILL.md +2 -6
  75. package/capabilities/product/skills/harness-product-user-stories/SKILL.md +2 -6
  76. package/capabilities/product/templates/delivery-plan.md +3 -17
  77. package/cli/adapters.js +9 -5
  78. package/cli/agents.js +45 -9
  79. package/cli/capabilities.js +39 -8
  80. package/cli/checkpoint.js +57 -0
  81. package/cli/claude.js +40 -0
  82. package/cli/comparison.js +46 -0
  83. package/cli/composition.js +1 -0
  84. package/cli/files.js +2 -1
  85. package/cli/harness.js +78 -20
  86. package/cli/main.js +51 -15
  87. package/cli/maven.js +28 -0
  88. package/cli/portable-config.js +121 -0
  89. package/cli/start-config.js +15 -0
  90. package/cli/start.js +138 -0
  91. package/cli/verification-report.js +1 -1
  92. package/config/agents.claude-code.json +100 -0
  93. package/config/agents.default.json +25 -5
  94. package/core/agents/documentation.md +5 -0
  95. package/core/agents/implementer.md +5 -0
  96. package/core/agents/orchestrator.md +5 -0
  97. package/core/agents/planner.md +5 -0
  98. package/core/agents/project-manager.md +20 -0
  99. package/core/agents/reviewer.md +5 -0
  100. package/core/agents/security-reviewer.md +5 -0
  101. package/core/agents/test-engineer.md +5 -0
  102. package/core/checks/verification.json +12 -0
  103. package/core/evals/cases.json +44 -0
  104. package/core/policies/permissions.md +3 -1
  105. package/core/workflows/agent-routing.md +27 -26
  106. package/core/workflows/continuous-development.md +3 -0
  107. package/core/workflows/project-setup.md +18 -0
  108. package/core/workflows/spec-to-code.md +1 -1
  109. package/docs/README.md +6 -1
  110. package/docs/agent-configuration.md +9 -5
  111. package/docs/agent-first.md +66 -0
  112. package/docs/agent-handoffs.md +4 -0
  113. package/docs/architecture.md +14 -0
  114. package/docs/cli.md +21 -0
  115. package/docs/configuration-backup.md +122 -0
  116. package/docs/continuous-development.md +4 -0
  117. package/docs/examples/astro-micro-tools.md +1 -1
  118. package/docs/examples/checkpoint.json +20 -0
  119. package/docs/examples/spring-boot-api.md +2 -4
  120. package/docs/getting-started.md +9 -6
  121. package/docs/i18n/README.es.md +35 -0
  122. package/docs/i18n/README.vi.md +21 -43
  123. package/docs/i18n/README.zh-CN.md +35 -0
  124. package/docs/project-configuration.md +9 -1
  125. package/docs/skill-matrix.md +7 -7
  126. package/docs/start.md +38 -0
  127. package/docs/tool-setup/README.md +69 -67
  128. package/docs/tool-setup/claude-code.md +41 -49
  129. package/docs/tool-setup/codex.md +31 -43
  130. package/docs/tool-setup/copilot.md +35 -38
  131. package/docs/tool-setup/cursor.md +25 -40
  132. package/docs/tool-setup/gemini-cli.md +31 -35
  133. package/docs/tool-setup/kiro.md +55 -0
  134. package/docs/versioning.md +24 -0
  135. package/evals/real-task-comparison.md +4 -0
  136. package/harness.yaml +4 -2
  137. package/package.json +3 -2
  138. package/scripts/compare-tasks.js +15 -0
  139. package/templates/AGENTS.md +20 -28
  140. package/templates/delivery-plan.md +25 -0
  141. package/adapters/astro/skills/harness-astro-cloudflare/SKILL.md +0 -27
  142. package/adapters/astro/skills/harness-astro-microfrontend/SKILL.md +0 -27
  143. package/adapters/java-spring/skills/harness-java-spring-database-migration/SKILL.md +0 -23
  144. 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
- if (Object.hasOwn(files, dest)) throw new HarnessError(`Skill collision: ${name}`);
78
- files[dest] = fs.readFileSync(safe(source, `capabilities/${c}/skills/${name}/SKILL.md`));
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 : 0o644);
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/).at(-1) !== '/machine.json') {
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
- if (!(lock.capabilities ?? []).includes(capability)) {
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 sourceName = `.harness/capabilities/${capability}/templates/${name}.md`;
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 && (capability !== 'product' || name !== 'techspec')) throw new HarnessError('--report is supported only for product techspec');
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 route = docRoute(root, capability, name);
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
- --host <id> setup only: codex,claude-code,gemini-cli,cursor,copilot or custom host ID
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
- --report <file> eval: score; verify: validate evidence; new-doc techspec: link evidence
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 → fill .harness/local.md → ask your AI host to read AGENTS.md.
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 (command === 'setup') allowed.add('host');
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 === 'init' || command === 'update') {
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 setup; ask your AI host to read AGENTS.md and the project-setup workflow.',
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 aih doctor and review/commit the shared configuration and snapshot.'];
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
+ }