@manhd_d/ai-harness 0.13.0 → 0.15.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 +57 -0
- package/README.md +22 -7
- package/adapters/react-native/adapter.yaml +1 -1
- package/adapters/react-native/skills/harness-react-native-behavior-testing/SKILL.md +9 -0
- package/adapters/react-nextjs/adapter.yaml +1 -1
- package/adapters/react-nextjs/skills/harness-react-nextjs-behavior-testing/SKILL.md +9 -0
- package/bin/aih.js +1 -1
- package/capabilities/product/capability.json +1 -1
- package/capabilities/product/workflows/idea-to-prd.md +15 -0
- package/capabilities/product/workflows/idea-to-prd.workflow.json +82 -0
- package/cli/adapters.js +1 -1
- package/cli/agents.js +14 -5
- package/cli/capabilities.js +2 -1
- package/cli/end-user.js +30 -0
- package/cli/environment.js +2 -0
- package/cli/evaluation-contract.js +11 -1
- package/cli/files.js +7 -2
- package/cli/harness.js +60 -15
- package/cli/journey-evidence.js +34 -0
- package/cli/kiro.js +15 -0
- package/cli/knowledge.js +104 -0
- package/cli/main.js +80 -19
- package/cli/portable-config.js +124 -0
- package/cli/project-decisions.js +47 -0
- package/cli/readiness.js +82 -41
- package/cli/setup.js +66 -0
- package/cli/skill-requirements.js +47 -0
- package/cli/start-config.js +15 -0
- package/cli/start.js +151 -0
- package/cli/structured.js +65 -0
- package/cli/sync-integrations.js +49 -0
- package/cli/verification-report.js +9 -5
- package/cli/verification.js +7 -2
- package/cli/workflow.js +76 -0
- package/core/agents/orchestrator.md +1 -1
- package/core/agents/test-engineer.md +4 -2
- package/core/checks/verification.json +12 -0
- package/core/evals/cases.json +97 -1
- package/core/rules/context.md +9 -0
- package/core/skills/harness-end-user-testing/SKILL.md +55 -0
- package/core/skills/harness-review/SKILL.md +4 -0
- package/core/skills/harness-spec/SKILL.md +3 -1
- package/core/skills/harness-start/SKILL.md +66 -0
- package/core/skills/harness-verification/SKILL.md +2 -0
- package/core/workflows/adaptive-verification.md +5 -0
- package/core/workflows/agent-routing.md +6 -0
- package/core/workflows/approvals.schema.json +62 -0
- package/core/workflows/clarification.md +6 -1
- package/core/workflows/continuous-development.md +5 -0
- package/core/workflows/end-user-acceptance.md +36 -0
- package/core/workflows/greenfield-setup.md +27 -0
- package/core/workflows/project-setup.md +30 -1
- package/core/workflows/seed.md +25 -0
- package/core/workflows/spec-to-code.md +19 -8
- package/core/workflows/spec-to-code.workflow.json +99 -0
- package/core/workflows/workflow.schema.json +113 -0
- package/docs/README.md +11 -0
- package/docs/adaptive-verification.md +10 -1
- package/docs/agent-configuration.md +7 -0
- package/docs/agent-handoffs.md +6 -1
- package/docs/architecture.md +53 -3
- package/docs/cli.md +55 -3
- package/docs/configuration-backup.md +130 -0
- package/docs/conformance-runner.md +64 -0
- package/docs/end-to-end-workflow.md +4 -0
- package/docs/end-user-testing.md +128 -0
- package/docs/frontend-mobile.md +7 -0
- package/docs/getting-started.md +21 -7
- package/docs/harness-maintenance.md +1 -0
- package/docs/i18n/README.es.md +18 -5
- package/docs/i18n/README.vi.md +19 -6
- package/docs/i18n/README.zh-CN.md +18 -5
- package/docs/project-configuration.md +15 -1
- package/docs/python-installation.md +85 -0
- package/docs/session-onboarding.md +215 -0
- package/docs/skill-matrix.md +5 -3
- package/docs/start.md +43 -0
- package/docs/tool-setup/README.md +13 -14
- package/docs/tool-setup/claude-code.md +10 -0
- package/docs/tool-setup/codex.md +10 -0
- package/docs/tool-setup/copilot.md +10 -0
- package/docs/tool-setup/cursor.md +10 -0
- package/docs/tool-setup/gemini-cli.md +10 -0
- package/docs/tool-setup/kiro.md +112 -38
- package/docs/versioning.md +67 -0
- package/docs/workflow-contract.md +66 -0
- package/evals/README.md +7 -0
- package/harness.yaml +13 -1
- package/package.json +7 -3
- package/scripts/conformance-runner.js +119 -0
- package/scripts/score-eval.js +3 -1
- package/templates/AGENTS.md +6 -0
- package/templates/decisions.json +4 -0
- package/templates/local.md +21 -0
- package/templates/openspec/approvals.json +5 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { safe, stat, digest, isObject, HarnessError } from './files.js';
|
|
3
|
+
const text = value => typeof value === 'string' && value.trim().length > 0 && !/[\0\r\n]/.test(value);
|
|
4
|
+
const assert = (ok, message) => { if (!ok) throw new HarnessError('Invalid user journey: ' + message); };
|
|
5
|
+
const interactions = { web: 'browser', mobile: 'device', cli: 'terminal', api: 'http' };
|
|
6
|
+
export function reviewJourney(root, row) {
|
|
7
|
+
if (row.journey === undefined) return { acceptance_scope: 'not-declared' };
|
|
8
|
+
const journey = row.journey;
|
|
9
|
+
assert(isObject(journey) && Object.keys(journey).every(k => ['surface', 'actor', 'entrypoint', 'interaction', 'dependencies', 'attachments'].includes(k)), 'fields');
|
|
10
|
+
assert(Object.hasOwn(interactions, journey.surface) && text(journey.actor) && text(journey.entrypoint), 'surface/actor/entrypoint');
|
|
11
|
+
assert(journey.interaction === interactions[journey.surface], 'surface must be exercised by its actual interface');
|
|
12
|
+
assert(['real', 'mocked', 'not-applicable', 'unknown'].includes(journey.dependencies), 'dependency classification');
|
|
13
|
+
if (/^https?:\/\//.test(journey.entrypoint)) {
|
|
14
|
+
let url; try { url = new URL(journey.entrypoint); } catch { throw new HarnessError('Invalid user journey: entrypoint URL'); }
|
|
15
|
+
assert(!url.username && !url.password && ![...url.searchParams.keys()].some(k => /^(?:token|access_token|api[_-]?key|password|secret|authorization|sig|signature)$/i.test(k)), 'entrypoint must not contain credentials');
|
|
16
|
+
}
|
|
17
|
+
const method = { 'browser-e2e': 'web', 'mobile-e2e': 'mobile', 'cli-e2e': 'cli', api: 'api', 'backend-e2e': 'api', authorization: 'api', security: 'api' }[row.method];
|
|
18
|
+
assert(method ? method === journey.surface : ['end-user', 'business-workflow'].includes(row.method), 'scenario method/surface mismatch');
|
|
19
|
+
assert(Array.isArray(journey.attachments) && journey.attachments.length <= 20, 'attachment list (max 20)');
|
|
20
|
+
const kinds = { screenshot: /\.(?:png|jpe?g|webp)$/i, trace: /\.zip$/i, video: /\.(?:webm|mp4)$/i, report: /\.(?:html|json)$/i };
|
|
21
|
+
const paths = new Set(); let totalSize = 0;
|
|
22
|
+
for (const ref of journey.attachments) {
|
|
23
|
+
assert(isObject(ref) && Object.keys(ref).sort().join(',') === 'kind,path,sha256' && Object.hasOwn(kinds, ref.kind)
|
|
24
|
+
&& text(ref.path) && kinds[ref.kind].test(ref.path) && /^[a-f0-9]{64}$/.test(ref.sha256), 'attachment type/path/digest');
|
|
25
|
+
assert(!paths.has(ref.path), 'duplicate attachment'); paths.add(ref.path);
|
|
26
|
+
const filename = safe(root, ref.path), info = stat(filename);
|
|
27
|
+
assert(info?.isFile() && info.size > 0 && info.size <= 50 * 1024 * 1024, 'attachment missing/empty/over 50 MiB');
|
|
28
|
+
totalSize += info.size; assert(totalSize <= 100 * 1024 * 1024, 'attachments exceed 100 MiB total');
|
|
29
|
+
assert(digest(fs.readFileSync(filename)) === ref.sha256, 'attachment digest mismatch');
|
|
30
|
+
}
|
|
31
|
+
const scope = !row.executed ? 'not-executed' : journey.dependencies === 'mocked' ? 'interface-only'
|
|
32
|
+
: journey.dependencies === 'unknown' ? 'unverified-dependencies' : 'end-user';
|
|
33
|
+
return { journey, acceptance_scope: scope };
|
|
34
|
+
}
|
package/cli/kiro.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { encode, HarnessError } from './files.js';
|
|
2
|
+
|
|
3
|
+
export const KIRO_HOOK = '.kiro/hooks/harness-session.json';
|
|
4
|
+
export function kiroPayload(files) {
|
|
5
|
+
if (!Object.hasOwn(files, ".agents/skills/harness-start/SKILL.md")) throw new HarnessError("Kiro onboarding requires a source bundle containing harness-start; use a reviewed update");
|
|
6
|
+
const result = {};
|
|
7
|
+
for (const [name, data] of Object.entries(files).filter(([p]) => /^\.agents\/skills\/harness-[a-z0-9-]+\/SKILL\.md$/.test(p))) {
|
|
8
|
+
const id = name.split('/')[2];
|
|
9
|
+
const description = data.toString().match(/^description: (.+)$/m)?.[1] ?? 'Load canonical Harness instructions.';
|
|
10
|
+
result['.kiro/skills/' + id + '/SKILL.md'] = Buffer.from('---\nname: ' + id + '\ndescription: ' + description + '\n---\n\nRead the complete canonical project file `' + name + '` before applying this skill. Follow its inputs, procedure, constraints and handoff.\n');
|
|
11
|
+
}
|
|
12
|
+
result[KIRO_HOOK] = encode({ version: 'v1', hooks: [{ name: 'Harness session context', trigger: 'SessionStart', action: { type: 'agent', prompt:
|
|
13
|
+
'Read AGENTS.md and .harness/local.md. Read .harness/start.json and .harness/decisions.json if present. Inspect .harness/knowledge.json if present and check declared-source freshness using the installed Harness CLI (aih seed --check), or compare its hashes with available host tools. Reuse applicable confirmed decisions; superseded/proposed choices are not current approvals. Load other instructions only when applicable. If setup is missing/stale, mention /harness-start. Do not start an interview, rewrite configuration or execute a saved task solely because this hook ran.' } }] });
|
|
14
|
+
return result;
|
|
15
|
+
}
|
package/cli/knowledge.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { safe, stat, digest, encode, isObject, HarnessError } from './files.js';
|
|
4
|
+
import { readConfig } from './project-config.js';
|
|
5
|
+
import { frontmatter } from './structured.js';
|
|
6
|
+
import { check, loadLock, guard, transaction } from './harness.js';
|
|
7
|
+
|
|
8
|
+
export const KNOWLEDGE = '.harness/knowledge.json';
|
|
9
|
+
const CONTEXT = ['.harness/local.md', '.harness/environment.json', '.harness/agents.json', '.harness/artifacts.json', '.harness/integrations.json', '.harness/decisions.json'];
|
|
10
|
+
const EXCLUDE = new Set(['.git', '.harness', '.agents', '.kiro', '.claude', 'node_modules', 'target', 'build', 'dist', '.next', '.venv', 'vendor']);
|
|
11
|
+
const RELEVANT = /^(?:AGENTS\.md|README\.md|OVERVIEW\.md|pom\.xml|package\.json|build\.gradle(?:\.kts)?|pyproject\.toml|[^/]*(?:conventions|workflow|testing)[^/]*\.md)$/i;
|
|
12
|
+
function assert(ok, message) { if (!ok) throw new HarnessError(message); }
|
|
13
|
+
function bytes(root, name) {
|
|
14
|
+
const p = safe(root, name), info = stat(p);
|
|
15
|
+
assert(!info || info.size <= 1024 * 1024, 'Knowledge source exceeds 1 MiB: ' + name);
|
|
16
|
+
return info ? fs.readFileSync(p) : null;
|
|
17
|
+
}
|
|
18
|
+
export function localContext(root) {
|
|
19
|
+
const body = bytes(root, '.harness/local.md')?.toString('utf8') ?? '';
|
|
20
|
+
const parsed = frontmatter(body, '.harness/local.md');
|
|
21
|
+
if (!parsed) return { structured: false, value: null };
|
|
22
|
+
const value = parsed.value;
|
|
23
|
+
assert(value.schema_version === 1 && Object.keys(value).every(k => ['schema_version', 'stack', 'build', 'approved_deviations', 'sources'].includes(k)), 'Invalid local.md schema');
|
|
24
|
+
for (const key of ['stack', 'build']) if (value[key] !== undefined) {
|
|
25
|
+
assert(isObject(value[key]) && Object.values(value[key]).every(v => typeof v === 'string' && v.length <= 512), 'Invalid local.md ' + key);
|
|
26
|
+
}
|
|
27
|
+
const deviations = value.approved_deviations ?? [];
|
|
28
|
+
assert(Array.isArray(deviations) && deviations.length <= 128 && deviations.every(v => isObject(v)
|
|
29
|
+
&& ['id', 'rule', 'reason'].every(k => typeof v[k] === 'string' && v[k].trim())
|
|
30
|
+
&& Object.keys(v).every(k => ['id', 'rule', 'reason', 'source'].includes(k)))
|
|
31
|
+
&& new Set(deviations.map(v => v.id)).size === deviations.length, 'Invalid approved_deviations');
|
|
32
|
+
const sources = value.sources ?? [];
|
|
33
|
+
assert(Array.isArray(sources) && sources.length <= 128 && new Set(sources).size === sources.length, 'Invalid local.md sources');
|
|
34
|
+
for (const name of sources) {
|
|
35
|
+
safe(root, name);
|
|
36
|
+
assert(!name.startsWith('.harness/') && !name.startsWith('.agents/') && !name.startsWith('.kiro/') && !name.startsWith('.claude/') && !/(?:^|\/)(?:\.git|node_modules|machine\.json|\.env(?:\..*)?)(?:\/|$)/.test(name), 'Private or unsupported knowledge source');
|
|
37
|
+
}
|
|
38
|
+
return { structured: true, value };
|
|
39
|
+
}
|
|
40
|
+
function discover(root) {
|
|
41
|
+
const files = []; let visited = 0;
|
|
42
|
+
function walk(folder, depth) {
|
|
43
|
+
for (const entry of fs.readdirSync(path.join(root, folder), { withFileTypes: true }).sort((a,b) => a.name.localeCompare(b.name))) {
|
|
44
|
+
assert(++visited <= 10000, 'Knowledge discovery exceeds 10000 entries; narrow the workspace');
|
|
45
|
+
const name = folder ? folder + '/' + entry.name : entry.name;
|
|
46
|
+
if (entry.isSymbolicLink()) continue;
|
|
47
|
+
if (entry.isDirectory() && depth < 4 && !EXCLUDE.has(entry.name)) walk(name, depth + 1);
|
|
48
|
+
else if (entry.isFile() && RELEVANT.test(entry.name)) { safe(root, name); files.push(name); }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
walk('', 0);
|
|
52
|
+
assert(files.length <= 256, 'Knowledge discovery exceeds 256 sources');
|
|
53
|
+
return files.sort();
|
|
54
|
+
}
|
|
55
|
+
function hashes(root, names) { return Object.fromEntries(names.map(name => [name, bytes(root, name)]).map(([name,data]) => [name, data === null ? null : digest(data)])); }
|
|
56
|
+
function state(root) {
|
|
57
|
+
const local = localContext(root), discovered = discover(root);
|
|
58
|
+
return { local, discovered, source_hashes: hashes(root, [...new Set([...discovered, ...(local.value?.sources ?? [])])].sort()), context_hashes: hashes(root, CONTEXT), bundle_sha256: loadLock(root).bundle_sha256 };
|
|
59
|
+
}
|
|
60
|
+
function savedKnowledge(root) {
|
|
61
|
+
const saved = readConfig(root, KNOWLEDGE);
|
|
62
|
+
if (!saved) { assert(!stat(safe(root, KNOWLEDGE)), 'Invalid knowledge schema'); return null; }
|
|
63
|
+
assert(isObject(saved) && saved.schema_version === 1 && Object.keys(saved).sort().join(',') === 'bundle_sha256,context_hashes,knowledge_hash,schema_version,source_hashes', 'Invalid knowledge schema');
|
|
64
|
+
for (const group of ['source_hashes', 'context_hashes']) {
|
|
65
|
+
assert(isObject(saved[group]) && Object.keys(saved[group]).length <= 384, 'Invalid knowledge hashes');
|
|
66
|
+
for (const [name, hash] of Object.entries(saved[group])) { safe(root, name); assert(hash === null || /^[a-f0-9]{64}$/.test(hash), 'Invalid knowledge digest'); }
|
|
67
|
+
}
|
|
68
|
+
const { knowledge_hash, ...content } = saved;
|
|
69
|
+
assert(knowledge_hash === digest(encode(content)), 'Knowledge digest mismatch');
|
|
70
|
+
return saved;
|
|
71
|
+
}
|
|
72
|
+
export function knowledgeReport(root) {
|
|
73
|
+
const current = state(root), saved = savedKnowledge(root);
|
|
74
|
+
const changed = [];
|
|
75
|
+
for (const group of ['source_hashes', 'context_hashes']) for (const name of new Set([...Object.keys(current[group]), ...Object.keys(saved?.[group] ?? {})])) {
|
|
76
|
+
if (current[group][name] !== saved?.[group]?.[name]) changed.push(name);
|
|
77
|
+
}
|
|
78
|
+
if (saved && current.bundle_sha256 !== saved.bundle_sha256) changed.push('.harness/lock.json');
|
|
79
|
+
return { status: !saved ? 'missing' : changed.length ? 'stale' : 'current', structured: current.local.structured,
|
|
80
|
+
changed_sources: [...new Set(changed)].sort(), sources: Object.keys(current.source_hashes),
|
|
81
|
+
knowledge_hash: saved?.knowledge_hash ?? null,
|
|
82
|
+
note: 'Local declared-source freshness only; remote revisions and facts remain host-verified. Summaries do not override scoped instructions.' };
|
|
83
|
+
}
|
|
84
|
+
export function seed(root, { record = false, checkOnly = false } = {}) {
|
|
85
|
+
assert(check(root).status === 'ok', 'Fix installation drift before seed');
|
|
86
|
+
if (!record) {
|
|
87
|
+
const report = knowledgeReport(root);
|
|
88
|
+
return { ...report, status: checkOnly ? report.status === 'current' ? 'ok' : 'blocked' : 'planned', freshness: report.status,
|
|
89
|
+
next_steps: ['Use /harness-start in your AI session; inspect changed/new sources, populate structured local.md and shared environment.json.',
|
|
90
|
+
'Keep personal paths in ignored machine.json. Record only evidence-backed facts and already approved deviations.',
|
|
91
|
+
'After reconciliation run aih seed --record; --check detects changes without updating the cache.'] };
|
|
92
|
+
}
|
|
93
|
+
return guard(root, () => {
|
|
94
|
+
const current = state(root);
|
|
95
|
+
assert(current.local.structured && current.local.value.sources?.length > 0, 'Populate structured local.md with inspected sources before recording knowledge');
|
|
96
|
+
assert(Object.values(current.source_hashes).every(v => v !== null), 'Resolve missing knowledge sources before recording');
|
|
97
|
+
const previous = savedKnowledge(root);
|
|
98
|
+
const value = { schema_version: 1, source_hashes: current.source_hashes, context_hashes: current.context_hashes, bundle_sha256: current.bundle_sha256 };
|
|
99
|
+
value.knowledge_hash = digest(encode(value));
|
|
100
|
+
if (previous?.knowledge_hash !== value.knowledge_hash) transaction(root, { [KNOWLEDGE]: encode(value) });
|
|
101
|
+
return { status: 'ok', path: KNOWLEDGE, knowledge_hash: value.knowledge_hash, changed: previous?.knowledge_hash !== value.knowledge_hash,
|
|
102
|
+
note: 'Recorded host-reconciled source hashes; this does not certify summary correctness.' };
|
|
103
|
+
});
|
|
104
|
+
}
|
package/cli/main.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
+
import { seed } from './knowledge.js';
|
|
2
|
+
import { syncIntegrations } from './sync-integrations.js';
|
|
3
|
+
import { startPlan, saveStart, guidedStart, compactStart } from './start.js';
|
|
4
|
+
import { setupProject } from './setup.js';
|
|
5
|
+
import { projectDecisionReport } from './project-decisions.js';
|
|
6
|
+
import { backupConfig, restoreConfig } from './portable-config.js';
|
|
1
7
|
import { listAdapters, detect, verify, evaluate } from './adapters.js';
|
|
2
8
|
import { listCapabilities, skillCatalog, readSkill } from './capabilities.js';
|
|
3
9
|
import { resolveModules } from './composition.js';
|
|
4
10
|
import { parseArgs } from 'node:util';
|
|
5
|
-
import { setupPlan } from './environment.js';
|
|
6
11
|
import { ready } from './readiness.js';
|
|
12
|
+
import { workflowReport } from './workflow.js';
|
|
7
13
|
import { resumePlan } from './checkpoint.js';
|
|
8
14
|
import { agentPlan } from './agents.js';
|
|
9
15
|
import { adaptiveVerification } from './verification.js';
|
|
10
16
|
import { reviewVerification } from './verification-report.js';
|
|
11
17
|
import { artifactReport } from './artifacts.js';
|
|
12
|
-
import { SOURCE, readJson, targetRoot, install, check, doctor, newChange, newDoc, loadLock, saveCheckpoint } from './harness.js';
|
|
18
|
+
import { SOURCE, readJson, targetRoot, install, check, doctor, newChange, changeSidecars, newDoc, loadLock, saveCheckpoint } from './harness.js';
|
|
13
19
|
import path from 'node:path';
|
|
14
20
|
|
|
15
21
|
const HELP = `aih — install reusable AI development rules into a project
|
|
@@ -29,21 +35,43 @@ Usage: aih <command> [options]
|
|
|
29
35
|
update Apply the installed CLI bundle to the project (no download)
|
|
30
36
|
check Check Harness file integrity, not application tests
|
|
31
37
|
doctor Check installation, configured paths and tool presence
|
|
32
|
-
|
|
38
|
+
seed Plan context loading; --record reconciled hashes or --check freshness
|
|
39
|
+
sync-integrations Import Kiro MCP aliases only; verify tools in the active host
|
|
40
|
+
change-sidecars <id> Add missing OpenSpec sidecars without overwriting existing files
|
|
41
|
+
start Guide intake; reuse saved answers and hand off to your AI host
|
|
42
|
+
backup Export user configuration (not docs/checkpoints/secrets)
|
|
43
|
+
restore Restore --from backup; --transfer reuses modules/model preferences
|
|
44
|
+
setup Suggest questions; --apply scaffolds missing user config only
|
|
45
|
+
decisions Read shared decisions; --scope module:<name> narrows context
|
|
33
46
|
artifacts Show input/output/storage and connection declarations (read-only)
|
|
47
|
+
workflow validate Validate installed workflow contracts (read-only)
|
|
34
48
|
ready <id> Check structural change readiness, not approval
|
|
35
49
|
new-change <id> Create an OpenSpec draft using installed templates
|
|
36
50
|
new-doc <type> Scaffold a locked capability template (default: product)
|
|
37
51
|
|
|
38
52
|
Options:
|
|
53
|
+
--apply setup: create missing user configs from the installed snapshot
|
|
54
|
+
--greenfield setup: select new-project guidance, not an adapter or architecture
|
|
55
|
+
--compact start: short read-only orientation; skips terminal intake/source scan
|
|
56
|
+
--record seed: record hashes after host context reconciliation
|
|
57
|
+
--check seed: read-only freshness check
|
|
58
|
+
--skill <name> doctor: check one canonical/project skill prerequisite contract
|
|
59
|
+
--goal <type> start: idea, docs, change, resume (saves with --task)
|
|
60
|
+
--task <text> start: task description; --change supplies resume checkpoint ID
|
|
61
|
+
--input <path/url> start: seed requirements only if no inputs declared
|
|
62
|
+
--output <path> start: draft docs directory; backup: archive path
|
|
63
|
+
--from <file> restore: project-relative archive file
|
|
64
|
+
--transfer restore: only modules/agent models; omit old autonomy
|
|
65
|
+
--replace restore: explicit replacement; automatic before-backup
|
|
39
66
|
--target <directory> Project directory (default: current directory)
|
|
40
67
|
--detect init only: opt in to detected stack selection
|
|
41
|
-
--stage <type> ready
|
|
68
|
+
--stage <type> ready: workflow stage ID; legacy implement/review stay compatible
|
|
69
|
+
--workflow <id> ready: opt into full workflow gates and approval records
|
|
42
70
|
--capability <id> new-doc only: installed template owner (default: product)
|
|
43
71
|
--agent-preset <id> init only: recommended (default), economy, custom; no overwrite
|
|
44
72
|
--link-claude init/update: opt in to a managed CLAUDE.md import
|
|
45
73
|
--claude-skills init/update: opt in to canonical .claude/skills wrappers
|
|
46
|
-
--host <id> init/setup/agents: codex,claude-code,gemini-cli,cursor,copilot or custom host ID
|
|
74
|
+
--host <id> init/update/setup/agents/start: kiro,codex,claude-code,gemini-cli,cursor,copilot or custom host ID
|
|
47
75
|
--scope <type> verify/eval: adapters (default), capabilities, core, all
|
|
48
76
|
--adaptive verify: discover a read-only project verification plan
|
|
49
77
|
--expected <sha> checkpoint: observed prior checkpoint digest for replacement
|
|
@@ -51,12 +79,15 @@ Options:
|
|
|
51
79
|
--adapters <a,b> init/resolve: java-spring,react-nextjs,react-native,ai-python,kubernetes,astro
|
|
52
80
|
--capabilities <a,b> init/resolve: uiux,product,architecture,delivery,devops,monetization
|
|
53
81
|
--source <directory> init/update/list/detect/resolve: trusted alternate bundle directory
|
|
54
|
-
--dry-run init/update: preview without persistent changes
|
|
82
|
+
--dry-run init/update/setup --apply: preview without persistent changes
|
|
55
83
|
--allow-major init/update: acknowledge a major version migration
|
|
56
84
|
-h, --help Show help
|
|
57
85
|
-v, --version Show installed CLI version
|
|
58
86
|
|
|
59
|
-
|
|
87
|
+
All hosts: init --host <host> → first AI chat: invoke/read harness-start.
|
|
88
|
+
Kiro exposes /harness-start; other clients can read .agents/skills/harness-start/SKILL.md.
|
|
89
|
+
User settings: .harness/; backup exports only documented user files.
|
|
90
|
+
Restore config before init on a fresh project; --transfer excludes old sources/tasks.
|
|
60
91
|
The host runs application tests; verify prints plans or validates supplied evidence.
|
|
61
92
|
Update a global CLI with npm install -g @manhd_d/ai-harness@latest,
|
|
62
93
|
then run aih update --dry-run, aih update, and aih check in the project.
|
|
@@ -95,8 +126,8 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
95
126
|
let args;
|
|
96
127
|
try {
|
|
97
128
|
args = parseArgs({ args: argv, allowPositionals: true, strict: true, tokens: true, options: {
|
|
98
|
-
target: { type: 'string' }, adapters: { type: 'string' }, capabilities: { type: 'string' }, capability: { type: 'string' }, source: { type: 'string' },
|
|
99
|
-
'agent-preset': { type: 'string' }, host: { type: 'string' }, expected: { type: 'string' },
|
|
129
|
+
apply: { type: 'boolean' }, greenfield: { type: 'boolean' }, compact: { type: 'boolean' }, record: { type: 'boolean' }, check: { type: 'boolean' }, skill: { type: 'string' }, 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' },
|
|
130
|
+
workflow: { type: 'string' }, 'agent-preset': { type: 'string' }, host: { type: 'string' }, expected: { type: 'string' },
|
|
100
131
|
'link-claude': { type: 'boolean' }, 'claude-skills': { type: 'boolean' }, adaptive: { type: 'boolean' }, detect: { type: 'boolean' }, report: { type: 'string' }, scope: { type: 'string' }, stage: { type: 'string' },
|
|
101
132
|
'dry-run': { type: 'boolean' }, 'allow-major': { type: 'boolean' },
|
|
102
133
|
help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
|
|
@@ -109,26 +140,56 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
109
140
|
if (args.values.help || !argv.length) { console.log(HELP); return 0; }
|
|
110
141
|
if (args.values.version) { console.log(readJson(path.join(SOURCE, 'package.json')).version); return 0; }
|
|
111
142
|
const [command, name, ...extra] = args.positionals;
|
|
112
|
-
if (!['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}`);
|
|
113
|
-
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');
|
|
143
|
+
if (!['workflow', 'decisions', 'seed', 'sync-integrations', 'change-sidecars', '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}`);
|
|
144
|
+
if (extra.length || (!['workflow', 'change-sidecars', 'new-change', 'new-doc', 'skill', 'ready', 'checkpoint', 'resume'].includes(command) && name !== undefined) || (['workflow', 'change-sidecars', 'new-change', 'new-doc', 'skill', 'ready', 'checkpoint', 'resume'].includes(command) && name === undefined)) throw new Error('Unexpected or missing positional argument; run aih --help');
|
|
114
145
|
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'] : [])]);
|
|
146
|
+
if (command === 'workflow' && name !== 'validate') throw new Error('Usage: aih workflow validate');
|
|
147
|
+
if (command === 'ready') allowed.add('workflow');
|
|
148
|
+
if (command === 'seed') { allowed.add('record'); allowed.add('check'); if (args.values.record && args.values.check) throw new Error('Choose --record or --check'); }
|
|
149
|
+
if (command === 'doctor') allowed.add('skill');
|
|
150
|
+
if (command === 'sync-integrations') { for (const key of ['host','from','dry-run']) allowed.add(key); }
|
|
151
|
+
if (command === 'start') {
|
|
152
|
+
for (const key of ['goal','task','change','input','output','host','compact']) allowed.add(key);
|
|
153
|
+
if (args.values.compact && ['goal','task','change','input','output'].some(k => args.values[k] !== undefined)) throw new Error('--compact cannot save intake answers');
|
|
154
|
+
}
|
|
155
|
+
if (command === 'decisions') allowed.add('scope');
|
|
156
|
+
if (command === 'setup') {
|
|
157
|
+
for (const key of ['apply','greenfield','dry-run']) allowed.add(key);
|
|
158
|
+
if (args.values['dry-run'] && !args.values.apply) throw new Error('setup --dry-run requires --apply');
|
|
159
|
+
}
|
|
160
|
+
if (command === 'backup') allowed.add('output');
|
|
161
|
+
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'); }
|
|
162
|
+
if (command === 'start' && !args.values.goal && ['task','change','input','output'].some(key => args.values[key] !== undefined)) throw new Error('start saving options require --goal');
|
|
115
163
|
if (command === 'checkpoint') { allowed.add('report'); allowed.add('expected'); if (!args.values.report) throw new Error('checkpoint requires --report'); }
|
|
116
164
|
if (command === 'new-doc') { allowed.add('capability'); allowed.add('report'); }
|
|
117
165
|
if (command === 'verify') { allowed.add('adaptive'); allowed.add('report'); }
|
|
118
166
|
if (args.values.adaptive && args.values.report) throw new Error('Choose --adaptive or --report');
|
|
119
167
|
if (args.values.scope && (args.values.adaptive || (command === 'verify' && args.values.report))) throw new Error('--scope applies only to advisory checks');
|
|
120
168
|
if (command === 'init') allowed.add('agent-preset');
|
|
121
|
-
if (['setup','agents','init'].includes(command)) allowed.add('host');
|
|
169
|
+
if (['setup','agents','init','update'].includes(command)) allowed.add('host');
|
|
122
170
|
if (['init','update'].includes(command)) { allowed.add('link-claude'); allowed.add('claude-skills'); }
|
|
123
171
|
for (const key of Object.keys(args.values)) if (!allowed.has(key)) throw new Error(`--${key} is not supported for ${command}`);
|
|
124
172
|
if (args.values.detect && args.values.adapters !== undefined) throw new Error('Choose either --detect or --adapters');
|
|
125
|
-
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`);
|
|
173
|
+
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`);
|
|
126
174
|
} catch (error) { console.error(JSON.stringify({ status: 'error', message: error.message })); return 2; }
|
|
127
175
|
try {
|
|
128
176
|
const { values, positionals: [command, name] } = args;
|
|
129
177
|
const root = targetRoot(values.target ?? process.cwd());
|
|
130
178
|
let result;
|
|
131
|
-
if (command === '
|
|
179
|
+
if (command === 'workflow') {
|
|
180
|
+
if (check(root).status !== 'ok') throw new Error('Fix installation drift before reading workflows');
|
|
181
|
+
result = workflowReport(path.join(root, '.harness'), loadLock(root).capabilities ?? []);
|
|
182
|
+
} else if (command === 'start') {
|
|
183
|
+
if (!values.compact && !values.goal && values.host !== 'kiro' && 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; });
|
|
184
|
+
result = values.compact ? compactStart(root, values.host) : 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);
|
|
185
|
+
} else if (command === 'setup') result = setupProject(root, { apply: values.apply, dryRun: values['dry-run'], greenfield: values.greenfield, host: values.host });
|
|
186
|
+
else if (command === 'decisions') { if (check(root).status !== 'ok') throw new Error('Fix installation drift before reading decisions'); result = projectDecisionReport(root, values.scope); }
|
|
187
|
+
else if (command === 'seed') result = seed(root, { record: values.record, checkOnly: values.check });
|
|
188
|
+
else if (command === 'sync-integrations') result = syncIntegrations(root, { host: values.host, from: values.from, dryRun: values['dry-run'] });
|
|
189
|
+
else if (command === 'change-sidecars') result = changeSidecars(root, name);
|
|
190
|
+
else if (command === 'backup') result = backupConfig(root, values.output);
|
|
191
|
+
else if (command === 'restore') result = restoreConfig(root, values.from, { dryRun: values['dry-run'], transfer: values.transfer, replace: values.replace });
|
|
192
|
+
else if (command === 'init' || command === 'update') {
|
|
132
193
|
const source = targetRoot(values.source ?? SOURCE);
|
|
133
194
|
const selection = values.detect ? detect(root, source).adapters : undefined;
|
|
134
195
|
if (values.detect && !selection.length) throw new Error('No stack detected; choose --adapters explicitly or omit --detect for core only');
|
|
@@ -162,19 +223,19 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
162
223
|
if (check(root).status !== 'ok') throw new Error('Fix installation drift before reading artifact declarations');
|
|
163
224
|
result = artifactReport(root);
|
|
164
225
|
}
|
|
165
|
-
else if (command === '
|
|
226
|
+
else if (command === 'ready') {
|
|
166
227
|
const state = check(root);
|
|
167
228
|
if (state.status !== 'ok') throw new Error('Fix installation drift before setup/readiness checks');
|
|
168
|
-
result =
|
|
229
|
+
result = ready(root, name, values.stage ?? 'implement', values.workflow);
|
|
169
230
|
}
|
|
170
231
|
else if (command === 'new-change') result = newChange(root, name);
|
|
171
232
|
else if (command === 'new-doc') result = newDoc(root, name, values.capability ?? 'product', { report: values.report });
|
|
172
|
-
else result = command === 'doctor' ? doctor(root) : check(root);
|
|
233
|
+
else result = command === 'doctor' ? doctor(root, values.skill) : check(root);
|
|
173
234
|
if (command === 'init' || command === 'update') result.next_steps = values['dry-run']
|
|
174
235
|
? ['Review the proposed changes, then repeat without --dry-run.']
|
|
175
|
-
: ['
|
|
236
|
+
: [values.host === 'kiro' || Object.keys(loadLock(root).files).some(p => p.startsWith('.kiro/skills/harness-')) ? 'Open this project in Kiro. In your first chat select /harness-start; the agent asks only missing questions and saves context.' : 'Run start with the same CLI (aih start or npx --yes @manhd_d/ai-harness start); open your AI host and send host_prompt.',
|
|
176
237
|
'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.',
|
|
177
|
-
'Run
|
|
238
|
+
'Run doctor and review/commit shared configuration and snapshot. Use backup; copy its file outside the repo. See docs/configuration-backup.md.'];
|
|
178
239
|
if (command === 'new-change') result.next_steps = [
|
|
179
240
|
'Resolve consequential ambiguity with the user and record answers/sources in decisions.json.',
|
|
180
241
|
'When artifact outputs are configured, record create/update/unchanged impact and source-write policy in artifact-impact.json.',
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { localContext } from './knowledge.js';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { safe, stat, encode, digest, isObject, SOURCE, VERSION, HarnessError } from './files.js';
|
|
7
|
+
import { projectConfig, guard, transaction, atomicWrite } from './harness.js';
|
|
8
|
+
import { loadEnvironment } from './environment.js';
|
|
9
|
+
import { loadAgentConfig } from './agents.js';
|
|
10
|
+
import { loadArtifactContract } from './artifacts.js';
|
|
11
|
+
import { loadStartConfig } from './start-config.js';
|
|
12
|
+
import { resolveModules } from './composition.js';
|
|
13
|
+
import { PROJECT_DECISIONS, loadProjectDecisions } from './project-decisions.js';
|
|
14
|
+
|
|
15
|
+
export const USER_FILES = ['.harness/project.json', '.harness/agents.json', '.harness/artifacts.json',
|
|
16
|
+
'.harness/integrations.json', '.harness/environment.json', '.harness/local.md', '.harness/start.json', PROJECT_DECISIONS, 'openspec/config.yaml'];
|
|
17
|
+
const MAX_FILE = 1024 * 1024, MAX_ARCHIVE = 12 * MAX_FILE;
|
|
18
|
+
const fail = (ok, message) => { if (!ok) throw new HarnessError(message); };
|
|
19
|
+
export const backupName = () => '.harness/backups/config-' + new Date().toISOString().replace(/[:.]/g, '-') + '-' + randomUUID() + '.json';
|
|
20
|
+
function readText(root, name) {
|
|
21
|
+
const filename = safe(root, name), info = stat(filename);
|
|
22
|
+
if (!info) return null;
|
|
23
|
+
fail(info.size <= MAX_FILE, 'Configuration exceeds 1 MiB: ' + name);
|
|
24
|
+
const bytes = fs.readFileSync(filename), content = bytes.toString('utf8');
|
|
25
|
+
fail(Buffer.from(content).equals(bytes), 'Configuration must be UTF-8: ' + name);
|
|
26
|
+
return content;
|
|
27
|
+
}
|
|
28
|
+
export function configArchive(root) {
|
|
29
|
+
const files = {};
|
|
30
|
+
for (const name of USER_FILES) {
|
|
31
|
+
const content = readText(root, name);
|
|
32
|
+
if (content !== null) files[name] = { content, sha256: digest(Buffer.from(content)) };
|
|
33
|
+
}
|
|
34
|
+
fail(Object.hasOwn(files, '.harness/project.json'), 'Missing .harness/project.json; initialize or restore project configuration first');
|
|
35
|
+
return { schema_version: 1, kind: 'ai-harness-user-config', cli_version: JSON.parse(fs.readFileSync(path.join(SOURCE, 'package.json'))).version,
|
|
36
|
+
files };
|
|
37
|
+
}
|
|
38
|
+
function validateArchive(value) {
|
|
39
|
+
fail(isObject(value) && Object.keys(value).sort().join(',') === 'cli_version,files,kind,schema_version'
|
|
40
|
+
&& value.schema_version === 1 && value.kind === 'ai-harness-user-config' && VERSION.test(value.cli_version)
|
|
41
|
+
&& isObject(value.files) && Object.hasOwn(value.files, '.harness/project.json'), 'Unsupported configuration backup schema');
|
|
42
|
+
for (const [name, file] of Object.entries(value.files)) {
|
|
43
|
+
fail(USER_FILES.includes(name), 'Backup contains an unapproved configuration path');
|
|
44
|
+
fail(isObject(file) && Object.keys(file).sort().join(',') === 'content,sha256' && typeof file.content === 'string'
|
|
45
|
+
&& Buffer.byteLength(file.content) <= MAX_FILE && /^[a-f0-9]{64}$/.test(file.sha256)
|
|
46
|
+
&& digest(Buffer.from(file.content)) === file.sha256, 'Backup content/digest mismatch: ' + name);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function validateFiles(files) {
|
|
50
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'aih-config-'));
|
|
51
|
+
try {
|
|
52
|
+
for (const [name, file] of Object.entries(files)) {
|
|
53
|
+
if (name.endsWith('.json')) { try { JSON.parse(file.content); } catch { throw new HarnessError('Invalid JSON configuration: ' + name); } }
|
|
54
|
+
const dest = safe(temp, name); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, file.content, { mode: 0o600 });
|
|
55
|
+
}
|
|
56
|
+
const config = projectConfig(temp, undefined, undefined, true);
|
|
57
|
+
resolveModules(SOURCE, config);
|
|
58
|
+
loadAgentConfig(temp); loadArtifactContract(temp); loadEnvironment(temp); loadStartConfig(temp); localContext(temp); loadProjectDecisions(temp);
|
|
59
|
+
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
|
60
|
+
}
|
|
61
|
+
function outputPath(root, name) {
|
|
62
|
+
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/')
|
|
63
|
+
&& (!name.startsWith('.harness/') || name.startsWith('.harness/backups/')), 'Use a backup path outside active Harness configuration');
|
|
64
|
+
const filename = safe(root, name); fail(!stat(filename), 'Backup destination already exists'); return filename;
|
|
65
|
+
}
|
|
66
|
+
export function backupConfig(root, output = backupName()) {
|
|
67
|
+
return guard(root, () => {
|
|
68
|
+
const archive = configArchive(root); validateArchive(archive); validateFiles(archive.files);
|
|
69
|
+
const filename = outputPath(root, output);
|
|
70
|
+
fs.mkdirSync(path.dirname(filename), { recursive: true });
|
|
71
|
+
fs.writeFileSync(filename, encode(archive), { flag: 'wx', mode: 0o600 });
|
|
72
|
+
return { status: 'ok', backup: output, included: Object.keys(archive.files),
|
|
73
|
+
excluded: ['machine.json', 'credentials and native host settings', 'managed snapshot and lock', 'custom skills', 'source/output document contents', 'OpenSpec changes, checkpoints and evidence'],
|
|
74
|
+
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.'] };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
export function restoreConfig(root, from, { dryRun = false, transfer = false, replace = false, writer } = {}) {
|
|
78
|
+
const filename = safe(root, from); fail(stat(filename)?.size <= MAX_ARCHIVE, 'Missing or oversized configuration backup');
|
|
79
|
+
const archive = readConfigArchive(filename); validateArchive(archive); validateFiles(archive.files);
|
|
80
|
+
return guard(root, () => {
|
|
81
|
+
const files = transfer ? Object.fromEntries(Object.entries(archive.files).filter(([name]) => ['.harness/project.json', '.harness/agents.json'].includes(name))) : archive.files;
|
|
82
|
+
if (transfer && files['.harness/agents.json']) {
|
|
83
|
+
const config = JSON.parse(files['.harness/agents.json'].content);
|
|
84
|
+
delete config.execution.autonomy; // Repository-scoped decisions never move to a different project.
|
|
85
|
+
for (const agent of Object.values(config.agents)) delete agent.extensions;
|
|
86
|
+
files['.harness/agents.json'] = { content: encode(config).toString() };
|
|
87
|
+
}
|
|
88
|
+
const changes = {}, conflicts = [];
|
|
89
|
+
for (const [name, file] of Object.entries(files)) {
|
|
90
|
+
const current = readText(root, name);
|
|
91
|
+
if (current === file.content) continue;
|
|
92
|
+
if (current !== null) conflicts.push(name);
|
|
93
|
+
changes[name] = Buffer.from(file.content);
|
|
94
|
+
}
|
|
95
|
+
fail(!conflicts.length || replace || dryRun, 'Existing configuration differs: ' + conflicts.join(', ') + '; review --dry-run --replace before replacement');
|
|
96
|
+
// Validate the prospective merged configuration before any target writes.
|
|
97
|
+
const merged = {};
|
|
98
|
+
for (const name of USER_FILES) {
|
|
99
|
+
const content = changes[name]?.toString() ?? readText(root, name);
|
|
100
|
+
if (content !== null) merged[name] = { content };
|
|
101
|
+
}
|
|
102
|
+
validateFiles(merged);
|
|
103
|
+
// Validate declared artifact paths against actual target symlinks, not only temporary files.
|
|
104
|
+
const artifacts = merged['.harness/artifacts.json'] ? JSON.parse(merged['.harness/artifacts.json'].content) : null;
|
|
105
|
+
for (const group of ['inputs','outputs']) for (const item of Object.values(artifacts?.[group] ?? {})) {
|
|
106
|
+
if (item.location.path) safe(root, item.location.path + (group === 'outputs' || stat(path.join(root, item.location.path))?.isDirectory() ? '/.artifact-probe' : ''));
|
|
107
|
+
}
|
|
108
|
+
let backup = null;
|
|
109
|
+
if (!dryRun && conflicts.length) {
|
|
110
|
+
const before = configArchive(root); backup = backupName(); outputPath(root, backup);
|
|
111
|
+
changes[backup] = encode(before);
|
|
112
|
+
}
|
|
113
|
+
if (!dryRun) transaction(root, changes, writer ?? ((filename, data) => atomicWrite(filename, data, filename === (backup ? safe(root, backup) : null) ? 0o600 : 0o644)));
|
|
114
|
+
return { status: dryRun ? 'planned' : 'ok', mode: transfer ? 'transfer' : 'restore', changes: Object.keys(changes), conflicts,
|
|
115
|
+
backup, omitted: Object.keys(archive.files).filter(name => !Object.hasOwn(files, name)),
|
|
116
|
+
next_steps: ['Run init on a fresh project, or update --dry-run then update on an installed project; run check.',
|
|
117
|
+
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.',
|
|
118
|
+
'Review host access, models, sources and write policies; importing declarations does not grant permission or connect accounts.'] };
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function readConfigArchive(filename) {
|
|
122
|
+
try { return JSON.parse(fs.readFileSync(filename, 'utf8')); }
|
|
123
|
+
catch { throw new HarnessError('Invalid configuration backup JSON'); }
|
|
124
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readConfig } from './project-config.js';
|
|
2
|
+
import { safe, stat, isObject, HarnessError } from './files.js';
|
|
3
|
+
|
|
4
|
+
export const PROJECT_DECISIONS = '.harness/decisions.json';
|
|
5
|
+
export const emptyProjectDecisions = () => ({ schema_version: 1, decisions: [] });
|
|
6
|
+
const text = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 4096 && !/[\x00-\x1f\x7f]/.test(value);
|
|
7
|
+
const id = value => typeof value === 'string' && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value);
|
|
8
|
+
const scope = value => value === 'project' || typeof value === 'string' && /^module:[a-z][a-z0-9-]*(?:\/[a-z][a-z0-9-]*)*$/.test(value);
|
|
9
|
+
const fail = (ok, label) => { if (!ok) throw new HarnessError('Invalid project decisions: ' + label); };
|
|
10
|
+
|
|
11
|
+
export function loadProjectDecisions(root) {
|
|
12
|
+
const value = readConfig(root, PROJECT_DECISIONS);
|
|
13
|
+
if (value === null && !stat(safe(root, PROJECT_DECISIONS))) return null;
|
|
14
|
+
fail(isObject(value) && value.schema_version === 1 && Object.keys(value).sort().join(',') === 'decisions,schema_version'
|
|
15
|
+
&& Array.isArray(value.decisions) && value.decisions.length <= 256, 'schema');
|
|
16
|
+
const rows = new Map();
|
|
17
|
+
for (const row of value.decisions) {
|
|
18
|
+
fail(isObject(row) && Object.keys(row).every(k => ['id', 'scope', 'status', 'question', 'answer', 'reason', 'source', 'superseded_by', 'do_not_ask_again'].includes(k))
|
|
19
|
+
&& id(row.id) && scope(row.scope) && ['proposed', 'confirmed', 'superseded'].includes(row.status)
|
|
20
|
+
&& text(row.question) && !rows.has(row.id), 'entry fields or duplicate ID');
|
|
21
|
+
for (const key of ['answer', 'reason', 'source']) fail(row[key] === undefined || text(row[key]), key);
|
|
22
|
+
if (row.status !== 'proposed') fail(['answer', 'reason', 'source'].every(k => text(row[k])), 'confirmed/superseded decision needs answer, reason and source');
|
|
23
|
+
fail(row.do_not_ask_again === undefined || typeof row.do_not_ask_again === 'boolean', 'do_not_ask_again');
|
|
24
|
+
fail(row.status === 'superseded' ? id(row.superseded_by) && row.superseded_by !== row.id : row.superseded_by === undefined, 'superseded_by');
|
|
25
|
+
rows.set(row.id, row);
|
|
26
|
+
}
|
|
27
|
+
for (const row of rows.values()) {
|
|
28
|
+
const seen = new Set(); let current = row;
|
|
29
|
+
while (current.status === 'superseded') {
|
|
30
|
+
fail(!seen.has(current.id), 'supersession cycle'); seen.add(current.id);
|
|
31
|
+
const next = rows.get(current.superseded_by);
|
|
32
|
+
fail(next && next.scope === current.scope && next.status !== 'proposed', 'replacement must exist in the same scope and be confirmed/superseded');
|
|
33
|
+
current = next;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function projectDecisionReport(root, requestedScope) {
|
|
40
|
+
fail(requestedScope === undefined || scope(requestedScope), 'requested scope');
|
|
41
|
+
const value = loadProjectDecisions(root);
|
|
42
|
+
const decisions = (value?.decisions ?? []).filter(row => requestedScope === undefined || row.scope === 'project' || row.scope === requestedScope);
|
|
43
|
+
return { status: 'ok', configured: value !== null, path: PROJECT_DECISIONS,
|
|
44
|
+
confirmed: decisions.filter(row => row.status === 'confirmed'),
|
|
45
|
+
proposed: decisions.filter(row => row.status === 'proposed'), superseded: decisions.filter(row => row.status === 'superseded'),
|
|
46
|
+
note: 'Recorded decisions are project context, not permissions. Reuse only while scope and sources remain applicable; changed evidence can require reconsideration, even with do_not_ask_again.' };
|
|
47
|
+
}
|