@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
package/cli/readiness.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { safe, stat, SLUG, isObject, HarnessError } from './files.js';
|
|
3
|
+
import { safe, stat, SLUG, isObject, HarnessError, readJson } from './files.js';
|
|
4
4
|
import { readConfig } from './project-config.js';
|
|
5
5
|
import { artifactImpact } from './artifacts.js';
|
|
6
|
+
import { loadWorkflow, readApprovals } from './workflow.js';
|
|
6
7
|
|
|
7
8
|
const text = s => typeof s === 'string' && s.trim().length > 0;
|
|
8
9
|
const draft = s => /\b(?:TODO|TBD|FIXME)\b|\{\{[^}]+\}\}/i.test(s);
|
|
@@ -20,9 +21,10 @@ function prose(body) {
|
|
|
20
21
|
return fence ? '' : line;
|
|
21
22
|
}).join('\n');
|
|
22
23
|
}
|
|
23
|
-
|
|
24
|
+
function structuralReady(root, id, stage = 'implement', checks) {
|
|
25
|
+
const has = name => !checks || checks.includes(name);
|
|
24
26
|
if (!SLUG.test(id) || id === 'archive') throw new HarnessError('Invalid change ID');
|
|
25
|
-
|
|
27
|
+
|
|
26
28
|
const base = 'openspec/changes/' + id;
|
|
27
29
|
const problems = [], documents = new Map();
|
|
28
30
|
const read = relative => {
|
|
@@ -35,41 +37,45 @@ export function ready(root, id, stage = 'implement') {
|
|
|
35
37
|
if (draft(content)) problems.push('Draft placeholders in ' + relative);
|
|
36
38
|
documents.set(relative, content);
|
|
37
39
|
};
|
|
38
|
-
for (const name of ['proposal.md', 'design.md'
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
40
|
+
if (has('spec-structure')) for (const name of ['proposal.md', 'design.md']) read(name);
|
|
41
|
+
if (has('spec-structure') || has('tasks-present') || has('tasks-complete')) read('tasks.md');
|
|
42
|
+
if (has('spec-structure')) {
|
|
43
|
+
// safe() validates parent segments using a file leaf; do not traverse symlinked folders.
|
|
44
|
+
safe(root, base + '/specs/probe.md');
|
|
45
|
+
const specsRoot = path.join(root, base, 'specs');
|
|
46
|
+
let count = 0;
|
|
47
|
+
const visit = (folder, relative, depth = 0) => {
|
|
48
|
+
if (depth > 12) throw new HarnessError('Spec directory nesting exceeds limit');
|
|
49
|
+
for (const entry of fs.readdirSync(folder, { withFileTypes: true })) {
|
|
50
|
+
if (entry.isSymbolicLink()) throw new HarnessError('Symlink in change specs');
|
|
51
|
+
if (entry.isDirectory()) visit(path.join(folder, entry.name), relative + '/' + entry.name, depth + 1);
|
|
52
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
53
|
+
if (++count > 100) throw new HarnessError('Too many spec documents');
|
|
54
|
+
const name = relative + '/' + entry.name;
|
|
55
|
+
read(name);
|
|
56
|
+
const body = documents.get(name) || '';
|
|
57
|
+
if (!/^### Requirement:/m.test(body) || !/^#### Scenario:/m.test(body) ||
|
|
58
|
+
!/\bWHEN\b/.test(body) || !/\bTHEN\b/.test(body)) problems.push('Missing requirement/scenario structure in ' + name);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
if (fs.existsSync(specsRoot)) visit(specsRoot, 'specs');
|
|
63
|
+
if (!count) problems.push('Missing delta specs');
|
|
64
|
+
}
|
|
65
|
+
if (has('decisions-resolved')) {
|
|
66
|
+
const decisions = readConfig(root, base + '/decisions.json');
|
|
67
|
+
if (!isObject(decisions) || decisions.schema_version !== 1 || typeof decisions.reviewed !== 'boolean' ||
|
|
68
|
+
!Array.isArray(decisions.decisions)) problems.push('Missing/invalid decisions.json; copy the installed template for legacy changes');
|
|
69
|
+
else {
|
|
70
|
+
if (!decisions.reviewed) problems.push('Consequential ambiguity review is not recorded');
|
|
71
|
+
const ids = new Set();
|
|
72
|
+
for (const row of decisions.decisions) {
|
|
73
|
+
if (!isObject(row) || !text(row.id) || ids.has(row.id) || !text(row.question) ||
|
|
74
|
+
!['open', 'resolved'].includes(row.status)) { problems.push('Invalid/duplicate decision record'); continue; }
|
|
75
|
+
ids.add(row.id);
|
|
76
|
+
if (row.status === 'open') problems.push('Unresolved decision: ' + row.id);
|
|
77
|
+
else if (!evidenceText(row.answer) || !evidenceText(row.source)) problems.push('Decision needs answer and source: ' + row.id);
|
|
55
78
|
}
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
if (fs.existsSync(specsRoot)) visit(specsRoot, 'specs');
|
|
59
|
-
if (!count) problems.push('Missing delta specs');
|
|
60
|
-
|
|
61
|
-
const decisions = readConfig(root, base + '/decisions.json');
|
|
62
|
-
if (!isObject(decisions) || decisions.schema_version !== 1 || typeof decisions.reviewed !== 'boolean' ||
|
|
63
|
-
!Array.isArray(decisions.decisions)) problems.push('Missing/invalid decisions.json; copy the installed template for legacy changes');
|
|
64
|
-
else {
|
|
65
|
-
if (!decisions.reviewed) problems.push('Consequential ambiguity review is not recorded');
|
|
66
|
-
const ids = new Set();
|
|
67
|
-
for (const row of decisions.decisions) {
|
|
68
|
-
if (!isObject(row) || !text(row.id) || ids.has(row.id) || !text(row.question) ||
|
|
69
|
-
!['open', 'resolved'].includes(row.status)) { problems.push('Invalid/duplicate decision record'); continue; }
|
|
70
|
-
ids.add(row.id);
|
|
71
|
-
if (row.status === 'open') problems.push('Unresolved decision: ' + row.id);
|
|
72
|
-
else if (!evidenceText(row.answer) || !evidenceText(row.source)) problems.push('Decision needs answer and source: ' + row.id);
|
|
73
79
|
}
|
|
74
80
|
}
|
|
75
81
|
let postMergeLevel = null, taskCount = 0, pending = 0;
|
|
@@ -82,11 +88,11 @@ export function ready(root, id, stage = 'implement') {
|
|
|
82
88
|
const task = line.match(/^\s*(?:[-*+]|\d+\.)\s+\[([ xX])\]/);
|
|
83
89
|
if (task && postMergeLevel === null) { taskCount++; if (task[1] === ' ') pending++; }
|
|
84
90
|
}
|
|
85
|
-
if (!taskCount) problems.push('Missing pre-merge task checklist');
|
|
86
|
-
if (stage === 'review' && pending) problems.push('Incomplete pre-merge tasks: ' + pending);
|
|
91
|
+
if ((has('tasks-present') || has('tasks-complete')) && !taskCount) problems.push('Missing pre-merge task checklist');
|
|
92
|
+
if ((checks ? has('tasks-complete') : stage === 'review') && pending) problems.push('Incomplete pre-merge tasks: ' + pending);
|
|
87
93
|
|
|
88
94
|
const skipped = [];
|
|
89
|
-
if (stage === 'review') {
|
|
95
|
+
if (checks ? has('verification-evidence') : stage === 'review') {
|
|
90
96
|
const verification = readConfig(root, base + '/verification.json');
|
|
91
97
|
if (!isObject(verification) || verification.schema_version !== 1 || !Array.isArray(verification.checks) ||
|
|
92
98
|
!verification.checks.length) problems.push('Missing verification evidence');
|
|
@@ -109,7 +115,7 @@ export function ready(root, id, stage = 'implement') {
|
|
|
109
115
|
if (!passed) problems.push('No completed passing check or manual review recorded');
|
|
110
116
|
}
|
|
111
117
|
}
|
|
112
|
-
problems.push(...artifactImpact(root, base, stage));
|
|
118
|
+
if (has('artifact-impact')) problems.push(...artifactImpact(root, base, stage === 'implement' ? 'implement' : 'review'));
|
|
113
119
|
return { status: problems.length ? 'blocked' : 'ready', change: id, stage, problems,
|
|
114
120
|
tasks: { pre_merge: taskCount, pending }, skipped_checks: skipped, executed: false,
|
|
115
121
|
next_steps: problems.length ? ['Resolve reported gaps; ask the user about unresolved consequential decisions.']
|
|
@@ -117,3 +123,38 @@ export function ready(root, id, stage = 'implement') {
|
|
|
117
123
|
: 'Review actual evidence and the diff before creating a PR.'],
|
|
118
124
|
note: 'Structural readiness only, not business approval, OpenSpec validation or proof of truthful test evidence.' };
|
|
119
125
|
}
|
|
126
|
+
|
|
127
|
+
export function ready(root, id, stage = 'implement', workflowId) {
|
|
128
|
+
if (!SLUG.test(id) || id === 'archive') throw new HarnessError('Invalid change ID');
|
|
129
|
+
// Snapshot-less legacy use keeps working. Installed legacy stages validate the new declaration,
|
|
130
|
+
// but do not retroactively add approvals to previously supported structural gates.
|
|
131
|
+
const legacy = workflowId === undefined && ['implement', 'review'].includes(stage);
|
|
132
|
+
const lockPath = safe(root, '.harness/lock.json'), lock = stat(lockPath) ? readJson(lockPath) : null;
|
|
133
|
+
const source = path.join(root, '.harness');
|
|
134
|
+
if (legacy) {
|
|
135
|
+
if (stat(safe(source, 'core/workflows/spec-to-code.workflow.json'))) loadWorkflow(source, 'spec-to-code', lock?.capabilities ?? []);
|
|
136
|
+
return structuralReady(root, id, stage);
|
|
137
|
+
}
|
|
138
|
+
const workflow = loadWorkflow(source, workflowId ?? 'spec-to-code', lock?.capabilities ?? []);
|
|
139
|
+
const selected = workflow.stages.find(s => s.id === stage);
|
|
140
|
+
if (!selected) throw new HarnessError('Invalid readiness stage: ' + stage);
|
|
141
|
+
const checks = selected.entry_gate?.deterministic ?? [];
|
|
142
|
+
const result = structuralReady(root, id, stage, checks);
|
|
143
|
+
const approvals = readApprovals(root, 'openspec/changes/' + id, workflow);
|
|
144
|
+
const from = selected.entry_gate?.approval_from;
|
|
145
|
+
if (from && !approvals.some(a => a.stage === from && a.role === workflow.stages.find(s => s.id === from).approval.role)) result.problems.push('Missing recorded approval from ' + from);
|
|
146
|
+
if (checks.includes('outputs-present')) {
|
|
147
|
+
for (const file of workflow.stages.flatMap(s => s.outputs ?? [])) {
|
|
148
|
+
const relative = workflow.id === 'spec-to-code' ? 'openspec/changes/' + id + '/' + file : file;
|
|
149
|
+
const info = stat(safe(root, relative));
|
|
150
|
+
if (!info || !info.size) result.problems.push('Missing/empty output: ' + relative);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
result.status = result.problems.length ? 'blocked' : 'ready';
|
|
154
|
+
result.next_steps = result.problems.length ? ['Resolve reported entry-gate gaps before entering this stage.']
|
|
155
|
+
: ['Enter ' + selected.id + ' with owner ' + selected.owner + '; follow the matching Markdown procedure.',
|
|
156
|
+
...(selected.approval ? ['Record actual external ' + selected.approval.role + ' review before completing this stage.'] : [])];
|
|
157
|
+
result.workflow = workflow.id; result.owner = selected.owner; result.approval_required = selected.approval ?? null;
|
|
158
|
+
result.note += ' Recorded approvals are declarations only: identity, authority, freshness and semantic truth are not authenticated. Entry readiness does not complete a stage or authorize its outputs.';
|
|
159
|
+
return result;
|
|
160
|
+
}
|
package/cli/setup.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { safe, stat, encode, HarnessError, CONFIG } from './files.js';
|
|
5
|
+
import { loadLock, snapshotCheck, projectConfig, guard, transaction, userIgnoreChanges } from './harness.js';
|
|
6
|
+
import { validateAgentConfig, loadAgentConfig, AGENT_CONFIG } from './agents.js';
|
|
7
|
+
import { loadEnvironment, setupPlan } from './environment.js';
|
|
8
|
+
import { loadArtifactContract, ARTIFACTS, INTEGRATIONS } from './artifacts.js';
|
|
9
|
+
import { localContext } from './knowledge.js';
|
|
10
|
+
import { loadStartConfig } from './start-config.js';
|
|
11
|
+
import { PROJECT_DECISIONS, emptyProjectDecisions, loadProjectDecisions } from './project-decisions.js';
|
|
12
|
+
|
|
13
|
+
function validateCandidate(root, changes) {
|
|
14
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'aih-setup-validate-'));
|
|
15
|
+
try {
|
|
16
|
+
for (const name of [CONFIG, AGENT_CONFIG, ARTIFACTS, INTEGRATIONS, '.harness/environment.json',
|
|
17
|
+
'.harness/machine.json', '.harness/local.md', '.harness/start.json', PROJECT_DECISIONS]) {
|
|
18
|
+
const p = safe(root, name), data = changes[name] ?? (stat(p) ? fs.readFileSync(p) : null);
|
|
19
|
+
if (data !== null) { const dest = safe(temp, name); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, data, { mode: 0o600 }); }
|
|
20
|
+
}
|
|
21
|
+
projectConfig(temp, undefined, undefined, true); loadAgentConfig(temp); loadEnvironment(temp);
|
|
22
|
+
loadArtifactContract(temp); loadStartConfig(temp); localContext(temp); loadProjectDecisions(temp);
|
|
23
|
+
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A cloned snapshot supplies templates; this operation never reinstalls or changes its payload/lock.
|
|
27
|
+
export function setupProject(root, { apply = false, dryRun = false, greenfield = false, host, writer } = {}) {
|
|
28
|
+
if (dryRun && !apply) throw new HarnessError('setup --dry-run requires --apply');
|
|
29
|
+
const operation = () => {
|
|
30
|
+
if (snapshotCheck(root).status !== 'ok') throw new HarnessError('Fix installation drift before setup');
|
|
31
|
+
const lock = loadLock(root), hasProject = Boolean(stat(safe(root, CONFIG)));
|
|
32
|
+
const config = hasProject ? projectConfig(root, undefined, undefined, true)
|
|
33
|
+
: { schema_version: 1, adapters: lock.adapters, capabilities: lock.capabilities ?? [] };
|
|
34
|
+
if (JSON.stringify([...config.adapters].sort()) !== JSON.stringify([...lock.adapters].sort())
|
|
35
|
+
|| JSON.stringify([...(config.capabilities ?? [])].sort()) !== JSON.stringify([...(lock.capabilities ?? [])].sort())) {
|
|
36
|
+
throw new HarnessError('Project selection changed; run update before setup');
|
|
37
|
+
}
|
|
38
|
+
loadAgentConfig(root); loadEnvironment(root); loadArtifactContract(root); loadStartConfig(root); localContext(root); loadProjectDecisions(root);
|
|
39
|
+
if (!hasProject && !apply) throw new HarnessError('Missing project.json; use setup --apply to scaffold from the installed snapshot');
|
|
40
|
+
const changes = {};
|
|
41
|
+
if (apply) {
|
|
42
|
+
const template = name => {
|
|
43
|
+
const p = '.harness/templates/' + name;
|
|
44
|
+
if (!Object.hasOwn(lock.files, p)) throw new HarnessError('Setup requires installed template ' + name + '; use a reviewed update');
|
|
45
|
+
return fs.readFileSync(safe(root, p));
|
|
46
|
+
};
|
|
47
|
+
const missingTemplate = (dest, name) => { if (!stat(safe(root, dest))) changes[dest] = template(name); };
|
|
48
|
+
if (!hasProject) changes[CONFIG] = encode(config);
|
|
49
|
+
if (!stat(safe(root, AGENT_CONFIG))) {
|
|
50
|
+
const data = template('agents.default.json'); validateAgentConfig(JSON.parse(data)); changes[AGENT_CONFIG] = data;
|
|
51
|
+
}
|
|
52
|
+
missingTemplate(ARTIFACTS, 'artifacts.json'); missingTemplate(INTEGRATIONS, 'integrations.json');
|
|
53
|
+
missingTemplate('.harness/environment.json', 'environment.json'); missingTemplate('.harness/local.md', 'local.md');
|
|
54
|
+
missingTemplate('openspec/config.yaml', 'openspec/config.yaml');
|
|
55
|
+
if (!stat(safe(root, PROJECT_DECISIONS))) changes[PROJECT_DECISIONS] = encode(emptyProjectDecisions());
|
|
56
|
+
Object.assign(changes, userIgnoreChanges(root));
|
|
57
|
+
validateCandidate(root, changes);
|
|
58
|
+
if (!dryRun) transaction(root, changes, writer);
|
|
59
|
+
}
|
|
60
|
+
return { ...(dryRun ? { status: 'planned' } : setupPlan(root, lock.adapters, process.env, host)),
|
|
61
|
+
changes: Object.keys(changes), applied: apply && !dryRun,
|
|
62
|
+
workflow: '.harness/core/workflows/' + (greenfield ? 'greenfield-setup.md' : 'seed.md'),
|
|
63
|
+
note: 'Missing-only configuration scaffolding from the installed snapshot. Existing answers, machine paths, managed files and lock are preserved; host inspection/interview establishes facts.' };
|
|
64
|
+
};
|
|
65
|
+
return apply ? guard(root, operation) : operation();
|
|
66
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { safe, stat, isObject, HarnessError } from './files.js';
|
|
3
|
+
import { parseStructured } from './structured.js';
|
|
4
|
+
import { loadEnvironment, environmentReport } from './environment.js';
|
|
5
|
+
import { loadArtifactContract } from './artifacts.js';
|
|
6
|
+
|
|
7
|
+
export function skillRequirements(body) {
|
|
8
|
+
const front = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(body)?.[1];
|
|
9
|
+
if (front === undefined) throw new HarnessError('Skill frontmatter is required');
|
|
10
|
+
const lines = front.split(/\r?\n/), starts = lines.map((l,i) => /^requires:/.test(l) ? i : -1).filter(i => i >= 0);
|
|
11
|
+
if (!starts.length) return { machine: [], environment: [], mcp: [] };
|
|
12
|
+
if (starts.length !== 1) throw new HarnessError('Duplicate skill requires');
|
|
13
|
+
let end = starts[0] + 1;
|
|
14
|
+
while (end < lines.length && (!lines[end].trim() || /^\s/.test(lines[end]))) end++;
|
|
15
|
+
const requires = parseStructured(lines.slice(starts[0], end).join('\n'), 'skill requires').requires;
|
|
16
|
+
if (!isObject(requires) || Object.keys(requires).some(k => !['machine','environment','mcp'].includes(k))) throw new HarnessError('Invalid skill requires');
|
|
17
|
+
const result = { machine: [], environment: [], mcp: [], ...requires };
|
|
18
|
+
for (const [group, entries] of Object.entries(result)) {
|
|
19
|
+
const pattern = group === 'mcp' ? /^[A-Za-z0-9_.-]+$/ : /^(?:tools|files|commands)\.[a-z][a-z0-9-]*(?:\.(?:executable|path|tool|args))?$/;
|
|
20
|
+
if (!Array.isArray(entries) || entries.length > 64 || new Set(entries).size !== entries.length || entries.some(v => typeof v !== 'string' || !pattern.test(v))) throw new HarnessError('Invalid skill requires.' + group);
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
export function requirementReport(root, name) {
|
|
25
|
+
if (typeof name !== 'string' || !/^[a-z][a-z0-9-]*$/.test(name)) throw new HarnessError('Invalid skill name');
|
|
26
|
+
// Project native/custom skills are explicit inputs too; do not scan all their bodies.
|
|
27
|
+
const candidates = ['.agents/skills/', '.kiro/skills/', 'plugins/'];
|
|
28
|
+
const relative = candidates.map(p => p + name + '/SKILL.md').find(p => stat(safe(root,p)));
|
|
29
|
+
if (!relative) throw new HarnessError('Skill not found: ' + name);
|
|
30
|
+
if (stat(safe(root,relative)).size > 65536) throw new HarnessError('Skill exceeds 64 KiB');
|
|
31
|
+
const requirements = skillRequirements(fs.readFileSync(safe(root,relative),'utf8'));
|
|
32
|
+
const env = loadEnvironment(root), presence = environmentReport(root), contract = loadArtifactContract(root);
|
|
33
|
+
const get = (value, reference) => reference.split('.').reduce((o,k) => o && Object.hasOwn(o,k) ? o[k] : undefined, value);
|
|
34
|
+
const missing = [], hostChecks = [];
|
|
35
|
+
for (const group of ['machine','environment']) for (const reference of requirements[group]) {
|
|
36
|
+
const value = get(group === 'machine' ? env.machine : env.shared, reference);
|
|
37
|
+
const [kind,id] = reference.split('.');
|
|
38
|
+
const entry = presence.entries.find(e => e.group === kind && e.id === id);
|
|
39
|
+
if (value === undefined || !entry || !['present','declared'].includes(entry.status)) missing.push(group + ':' + reference);
|
|
40
|
+
}
|
|
41
|
+
for (const binding of requirements.mcp) {
|
|
42
|
+
if (!Object.values(contract.integrations.connections).some(c => c.via === 'mcp' && c.binding === binding)) missing.push('mcp:' + binding);
|
|
43
|
+
else hostChecks.push({ binding, status: 'unverified' });
|
|
44
|
+
}
|
|
45
|
+
return { status: missing.length ? 'blocked' : 'ok', skill: name, path: relative, requirements, missing, host_checks: hostChecks,
|
|
46
|
+
note: 'Local prerequisites only. MCP access/tools still require verification in the active host; values are omitted.' };
|
|
47
|
+
}
|
|
@@ -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
|
+
}
|
package/cli/start.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { knowledgeReport, localContext } from './knowledge.js';
|
|
2
|
+
import { projectDecisionReport } from './project-decisions.js';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { createInterface } from 'node:readline/promises';
|
|
7
|
+
import { safe, stat, encode, SLUG, HarnessError } from './files.js';
|
|
8
|
+
import { check, loadLock, guard, transaction, atomicWrite } from './harness.js';
|
|
9
|
+
import { setupPlan } from './environment.js';
|
|
10
|
+
import { loadArtifactContract, emptyArtifacts } from './artifacts.js';
|
|
11
|
+
import { START_CONFIG, GOALS, loadStartConfig } from './start-config.js';
|
|
12
|
+
import { configArchive, backupName } from './portable-config.js';
|
|
13
|
+
import { resumePlan } from './checkpoint.js';
|
|
14
|
+
|
|
15
|
+
function assert(ok, message) { if (!ok) throw new HarnessError(message); }
|
|
16
|
+
export function compactStart(root, host) {
|
|
17
|
+
assert(check(root).status === 'ok', 'Fix installation drift before start');
|
|
18
|
+
const lock = loadLock(root), saved = loadStartConfig(root), local = localContext(root);
|
|
19
|
+
return { status: 'planned', version: lock.version, adapters: lock.adapters,
|
|
20
|
+
task: saved?.task ?? null, change: saved?.change_id ?? null, stack: local.value?.stack ?? null,
|
|
21
|
+
context: { structured: local.structured, freshness: 'unchecked' },
|
|
22
|
+
next_action: host === 'kiro' ? '/harness-start' : 'Read .agents/skills/harness-start/SKILL.md',
|
|
23
|
+
note: 'Orientation only; no source scan, MCP probe, interview or task execution. Check freshness with seed --check.' };
|
|
24
|
+
}
|
|
25
|
+
export function startPlan(root, host) {
|
|
26
|
+
assert(check(root).status === 'ok', 'Fix installation drift before start');
|
|
27
|
+
const lock = loadLock(root), saved = loadStartConfig(root), contract = loadArtifactContract(root);
|
|
28
|
+
const evidence = ['README.md', 'docs', 'requirements', 'pom.xml', 'package.json', 'build.gradle', 'pyproject.toml']
|
|
29
|
+
.filter(name => { const p = path.join(root, name), info = stat(p); if (info?.isDirectory()) safe(root, name + '/.start-probe'); else safe(root, name); return Boolean(info); });
|
|
30
|
+
const base = path.join(root, 'openspec/changes'), checkpoints = [];
|
|
31
|
+
if (stat(base)) {
|
|
32
|
+
safe(root, 'openspec/changes/.start-probe');
|
|
33
|
+
for (const id of fs.readdirSync(base).sort().slice(0, 100)) {
|
|
34
|
+
if (id !== 'archive' && SLUG.test(id) && stat(safe(root, 'openspec/changes/' + id + '/checkpoint.json'))) checkpoints.push(id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const recommendation = saved?.goal ?? (checkpoints.length ? 'resume' : contract.artifacts.mode === 'document-driven' ? 'docs'
|
|
38
|
+
: evidence.some(p => ['pom.xml', 'package.json', 'build.gradle', 'pyproject.toml'].includes(p)) ? 'change' : 'idea');
|
|
39
|
+
const goal = saved?.goal;
|
|
40
|
+
const workflow = goal === 'idea' ? 'solution-framing.md' : 'continuous-development.md';
|
|
41
|
+
const setup = setupPlan(root, lock.adapters, process.env, host);
|
|
42
|
+
let resume = null;
|
|
43
|
+
if (goal === 'resume') {
|
|
44
|
+
try { resume = resumePlan(root, saved.change_id); }
|
|
45
|
+
catch (error) { if (!(error instanceof HarnessError)) throw error; resume = { status: 'blocked', executed: false, message: error.message, next_action: 'Restore/reconcile checkpoints and evidence before continuation, or choose a new task.' }; }
|
|
46
|
+
}
|
|
47
|
+
return { status: resume?.status === 'blocked' ? 'blocked' : 'planned', saved, discovery: { evidence, checkpoints, inputs: Object.keys(contract.artifacts.inputs), outputs: Object.keys(contract.artifacts.outputs) },
|
|
48
|
+
knowledge: knowledgeReport(root),
|
|
49
|
+
project_decisions: projectDecisionReport(root),
|
|
50
|
+
recommendation, reason: saved ? 'Reuse the saved task; choose a new goal explicitly to start another task.'
|
|
51
|
+
: checkpoints.length ? 'Checkpoint candidates exist; inspect freshness before continuing.'
|
|
52
|
+
: 'Based on declared sources and root build-file presence; the host must inspect their contents.',
|
|
53
|
+
questions: [ ...(!saved ? [{ id: 'goal', question: 'Start from an idea, existing docs, change existing code, or resume?', options: GOALS, recommendation }] : []),
|
|
54
|
+
...(!Object.keys(contract.artifacts.inputs).length && goal !== 'resume' ? [{ id: 'input', question: 'Use which requirement path or credential-free HTTPS URL? Leave unknowns for the AI discussion.', options: ['repository', 'external', 'idea'], recommendation: evidence.includes('docs') ? 'repository' : 'idea' }] : []),
|
|
55
|
+
...(!Object.keys(contract.artifacts.outputs).length && ['idea','docs'].includes(goal) ? [{ id: 'output', question: 'Where should draft docs be written?', options: ['docs/product', 'existing destination', 'discuss with AI'], recommendation: 'docs/product' }] : []) ],
|
|
56
|
+
suggested_roles: goal === 'idea' ? ['product:business-analyst', 'architecture:solution-architect']
|
|
57
|
+
: goal === 'docs' ? ['core:planner', 'core:implementer', 'core:reviewer'] : ['core:implementer', 'core:reviewer'],
|
|
58
|
+
agent_routes: setup.agent_configuration.roles.filter(role => (goal === 'idea' ? ['product:business-analyst', 'architecture:solution-architect'] : goal === 'docs' ? ['core:planner', 'core:implementer', 'core:reviewer'] : ['core:implementer', 'core:reviewer']).includes(role.canonical_id)),
|
|
59
|
+
installed_modules: { adapters: lock.adapters, capabilities: lock.capabilities ?? [] },
|
|
60
|
+
setup_questions: setup.questions, environment: setup.environment,
|
|
61
|
+
resume,
|
|
62
|
+
configuration: { directory: '.harness/', task: START_CONFIG, inputs_outputs: '.harness/artifacts.json', models_agents: '.harness/agents.json',
|
|
63
|
+
backup: 'aih backup', restore: 'aih restore --from <project-relative-backup.json>', transfer: 'aih restore --from <backup.json> --transfer' },
|
|
64
|
+
host_prompt: (Object.hasOwn(lock.files, '.agents/skills/harness-start/SKILL.md') ? 'Read .agents/skills/harness-start/SKILL.md and follow its session onboarding procedure. ' : '') + 'Read AGENTS.md, .harness/start.json if present, .harness/local.md, .harness/decisions.json if present and .harness/core/workflows/project-setup.md. '
|
|
65
|
+
+ 'Inspect declared input/output locations and current code. Reuse saved answers; ask only missing consequential decisions with options and a grounded recommendation. '
|
|
66
|
+
+ 'Use .harness/core/workflows/' + workflow + '. Discover only relevant installed agents/skills; suggest missing modules before editing project.json. '
|
|
67
|
+
+ 'Keep model defaults unless requested; save accepted source/output/connection choices in their project-owned configs. '
|
|
68
|
+
+ 'For resumed tasks inspect checkpoint freshness and side effects. Implement accepted scope with actual verification and maintain affected docs.',
|
|
69
|
+
next_steps: [host === 'kiro' ? 'In Kiro select /harness-start; complete the interview in the current AI session. This CLI plan never asks terminal questions for --host kiro.' : 'In a terminal start interviews and saves answers; without a TTY it prints this plan. Use --goal/--task to save non-interactively.',
|
|
70
|
+
'Open this project in your AI tool and send host_prompt; CLI does not launch a model or read remote document contents.',
|
|
71
|
+
'Use backup and copy its file outside the repository; see the configuration backup guide.'] };
|
|
72
|
+
}
|
|
73
|
+
export function saveStart(root, { goal, task, changeId, input, output, host } = {}) {
|
|
74
|
+
assert(GOALS.includes(goal), 'Choose --goal idea|docs|change|resume');
|
|
75
|
+
assert(typeof task === 'string' && task.trim().length > 0 && task.length <= 4096 && !/[\x00-\x1f\x7f]/.test(task), 'Supply a nonempty single-line --task (max 4096 characters)');
|
|
76
|
+
assert(goal === 'resume' ? SLUG.test(changeId ?? '') && changeId !== 'archive' : changeId === undefined, 'Use --change only with resume and an existing change ID');
|
|
77
|
+
return guard(root, () => {
|
|
78
|
+
const plan = startPlan(root, host), contract = loadArtifactContract(root);
|
|
79
|
+
if (goal === 'resume') {
|
|
80
|
+
assert(plan.discovery.checkpoints.includes(changeId), 'Resume requires an existing checkpoint; restore project changes/evidence first');
|
|
81
|
+
resumePlan(root, changeId); // Validate checkpoint/evidence before saving any configuration.
|
|
82
|
+
}
|
|
83
|
+
assert(goal !== 'resume' || (input === undefined && output === undefined), 'Resume reuses source/output contracts; do not combine with --input/--output');
|
|
84
|
+
const changes = {}, value = { schema_version: 1, goal, task: task.trim(), ...(changeId ? { change_id: changeId } : {}) };
|
|
85
|
+
changes[START_CONFIG] = encode(value);
|
|
86
|
+
if (input !== undefined || output !== undefined) {
|
|
87
|
+
const artifacts = structuredClone(contract.artifacts);
|
|
88
|
+
if (!contract.configured) Object.assign(artifacts, emptyArtifacts(), { mode: input ? 'document-driven' : goal === 'docs' ? 'document-driven' : 'idea-driven' });
|
|
89
|
+
if (input !== undefined) {
|
|
90
|
+
assert(!Object.keys(artifacts.inputs).length, 'Inputs already configured; review artifacts.json instead of replacing them through start');
|
|
91
|
+
assert(typeof input === 'string' && input.trim().length > 0, 'Input cannot be empty');
|
|
92
|
+
artifacts.inputs.requirements = { kind: 'requirements', location: input.startsWith('https://') ? { url: input } : { path: input }, write_policy: 'read-only' };
|
|
93
|
+
}
|
|
94
|
+
if (output !== undefined) {
|
|
95
|
+
assert(!Object.keys(artifacts.outputs).length, 'Outputs already configured; review artifacts.json instead of replacing them through start');
|
|
96
|
+
artifacts.outputs.documents = { kind: 'docs', location: { path: output }, capability: 'product', write_policy: 'propose', sources: Object.keys(artifacts.inputs) };
|
|
97
|
+
}
|
|
98
|
+
// Reuse the existing contract validator in a temporary workspace, before touching the target.
|
|
99
|
+
validateProposed(root, artifacts);
|
|
100
|
+
changes['.harness/artifacts.json'] = encode(artifacts);
|
|
101
|
+
}
|
|
102
|
+
for (const [name, bytes] of Object.entries(changes)) if (stat(safe(root, name)) && fs.readFileSync(safe(root, name)).equals(bytes)) delete changes[name];
|
|
103
|
+
const replacing = Object.keys(changes).some(name => stat(safe(root, name)));
|
|
104
|
+
let backup = null;
|
|
105
|
+
if (replacing) { backup = backupName(); assert(!stat(safe(root, backup)), 'Backup collision'); changes[backup] = encode(configArchive(root)); }
|
|
106
|
+
transaction(root, changes, (filename, data) => atomicWrite(filename, data, filename === (backup ? safe(root, backup) : null) ? 0o600 : 0o644));
|
|
107
|
+
const next = startPlan(root, host);
|
|
108
|
+
return { ...next, status: next.status === 'blocked' ? 'blocked' : 'ok', changes: Object.keys(changes), backup };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function validateProposed(root, artifacts) {
|
|
112
|
+
// Validate cross-file references with existing integrations and validate target paths through the canonical loader.
|
|
113
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'aih-start-'));
|
|
114
|
+
try {
|
|
115
|
+
fs.mkdirSync(path.join(temp, '.harness'));
|
|
116
|
+
fs.writeFileSync(path.join(temp, '.harness/artifacts.json'), encode(artifacts));
|
|
117
|
+
fs.writeFileSync(path.join(temp, '.harness/integrations.json'), encode(loadArtifactContract(root).integrations));
|
|
118
|
+
loadArtifactContract(temp);
|
|
119
|
+
for (const group of ['inputs', 'outputs']) for (const item of Object.values(artifacts[group])) {
|
|
120
|
+
const name = item.location.path;
|
|
121
|
+
if (name) safe(root, name + (group === 'outputs' || stat(path.join(root, name))?.isDirectory() ? '/.artifact-probe' : ''));
|
|
122
|
+
}
|
|
123
|
+
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function guidedStart(root, { host, ask } = {}) {
|
|
127
|
+
const plan = startPlan(root, host);
|
|
128
|
+
let rl;
|
|
129
|
+
if (!ask) { rl = createInterface({ input: process.stdin, output: process.stdout }); ask = q => rl.question(q); }
|
|
130
|
+
try {
|
|
131
|
+
let goal = plan.saved?.goal;
|
|
132
|
+
if (plan.saved) {
|
|
133
|
+
const answer = (await ask('Saved task: ' + plan.saved.task + '\n1. Continue (recommended) 2. New task\nChoice [1]: ')).trim();
|
|
134
|
+
assert(['', '1', '2'].includes(answer), 'Choose 1 or 2');
|
|
135
|
+
if (answer !== '2') return plan;
|
|
136
|
+
goal = undefined;
|
|
137
|
+
}
|
|
138
|
+
if (!goal) {
|
|
139
|
+
const choice = (await ask('Goal: 1. Idea 2. Existing docs 3. Change code 4. Resume\nSuggested: ' + plan.recommendation + '\nChoice [Enter uses suggestion]: ')).trim();
|
|
140
|
+
goal = choice ? GOALS[Number(choice) - 1] : plan.recommendation;
|
|
141
|
+
assert(GOALS.includes(goal), 'Choose 1, 2, 3 or 4');
|
|
142
|
+
}
|
|
143
|
+
const changeId = goal === 'resume' ? (await ask('Checkpoint ID (' + plan.discovery.checkpoints.join(', ') + '): ')).trim() : undefined;
|
|
144
|
+
const task = (await ask('What should the AI do? ')).trim();
|
|
145
|
+
let input, output;
|
|
146
|
+
if (!plan.discovery.inputs.length && goal !== 'resume') input = (await ask('Requirements: relative path or HTTPS URL (Enter: discuss idea/unknown sources with AI): ')).trim() || undefined;
|
|
147
|
+
if (!plan.discovery.outputs.length && ['idea', 'docs'].includes(goal)) output = (await ask('Draft docs folder [docs/product; type - to discuss with AI]: ')).trim() || 'docs/product';
|
|
148
|
+
if (output === '-') output = undefined;
|
|
149
|
+
return saveStart(root, { goal, task, changeId, input, output, host });
|
|
150
|
+
} finally { rl?.close(); }
|
|
151
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { HarnessError, isObject } from './files.js';
|
|
2
|
+
|
|
3
|
+
// Deliberately bounded YAML subset: mappings, sequences, JSON inline values and scalars.
|
|
4
|
+
// No tags, anchors, aliases, block scalars, implicit dates or object construction.
|
|
5
|
+
export function parseStructured(text, label = 'structured data') {
|
|
6
|
+
const fail = () => { throw new HarnessError('Invalid or unsupported YAML: ' + label); };
|
|
7
|
+
if (text.length > 65536) fail();
|
|
8
|
+
const clean = text.trim();
|
|
9
|
+
if (clean.startsWith('{')) {
|
|
10
|
+
try { const value = JSON.parse(clean); if (!isObject(value)) fail(); return value; }
|
|
11
|
+
catch { fail(); }
|
|
12
|
+
}
|
|
13
|
+
const lines = text.split(/\r?\n/).filter(l => l.trim() && !l.trim().startsWith('#'));
|
|
14
|
+
if (lines.some(l => /\t/.test(l))) fail();
|
|
15
|
+
const indent = l => l.length - l.trimStart().length;
|
|
16
|
+
const scalar = s => {
|
|
17
|
+
if (!s || /^[!&*|>]/.test(s)) fail();
|
|
18
|
+
if (s.startsWith('"') || s.startsWith('[') || s.startsWith('{')) {
|
|
19
|
+
try { return JSON.parse(s); } catch { fail(); }
|
|
20
|
+
}
|
|
21
|
+
if (s.startsWith("'")) { if (!s.endsWith("'")) fail(); return s.slice(1, -1).replaceAll("''", "'"); }
|
|
22
|
+
if (s === 'true') return true;
|
|
23
|
+
if (s === 'false') return false;
|
|
24
|
+
if (s === 'null') return null;
|
|
25
|
+
if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(s)) return Number(s);
|
|
26
|
+
return s;
|
|
27
|
+
};
|
|
28
|
+
let index = 0;
|
|
29
|
+
function block(depth, level = 0) {
|
|
30
|
+
if (level > 12 || index >= lines.length || indent(lines[index]) !== depth) fail();
|
|
31
|
+
const array = lines[index].trimStart().startsWith('- '), result = array ? [] : {};
|
|
32
|
+
function pair(line, object, keyDepth) {
|
|
33
|
+
const match = /^([A-Za-z_][A-Za-z0-9_-]*):(?:\s+(.*))?$/.exec(line);
|
|
34
|
+
if (!match || ['__proto__', 'constructor', 'prototype'].includes(match[1]) || Object.hasOwn(object, match[1])) fail();
|
|
35
|
+
const value = match[2];
|
|
36
|
+
object[match[1]] = value !== undefined ? scalar(value.trim())
|
|
37
|
+
: index < lines.length && indent(lines[index]) > keyDepth ? block(indent(lines[index]), level + 1) : null;
|
|
38
|
+
}
|
|
39
|
+
while (index < lines.length && indent(lines[index]) >= depth) {
|
|
40
|
+
if (indent(lines[index]) !== depth) fail();
|
|
41
|
+
const line = lines[index++].trimStart();
|
|
42
|
+
if (array) {
|
|
43
|
+
if (!line.startsWith('- ')) fail();
|
|
44
|
+
const value = line.slice(2);
|
|
45
|
+
if (/^[A-Za-z_][A-Za-z0-9_-]*:(?:\s|$)/.test(value)) {
|
|
46
|
+
const item = {}; pair(value, item, depth + 2);
|
|
47
|
+
while (index < lines.length && indent(lines[index]) === depth + 2) pair(lines[index++].trimStart(), item, depth + 2);
|
|
48
|
+
result.push(item);
|
|
49
|
+
} else result.push(scalar(value));
|
|
50
|
+
} else pair(line, result, depth);
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
if (!lines.length) return {};
|
|
55
|
+
const value = block(0);
|
|
56
|
+
if (!isObject(value) || index !== lines.length) fail();
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function frontmatter(body, label) {
|
|
61
|
+
if (!body.startsWith('---\n') && !body.startsWith('---\r\n')) return null;
|
|
62
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(body);
|
|
63
|
+
if (!match) throw new HarnessError('Incomplete frontmatter: ' + label);
|
|
64
|
+
return { raw: match[1], value: parseStructured(match[1], label) };
|
|
65
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { stat, isObject, SLUG, encode, safe, HarnessError } from './files.js';
|
|
5
|
+
import { loadArtifactContract, INTEGRATIONS } from './artifacts.js';
|
|
6
|
+
import { check, guard, transaction } from './harness.js';
|
|
7
|
+
|
|
8
|
+
export function syncIntegrations(root, { host = 'kiro', from, dryRun = false } = {}) {
|
|
9
|
+
if (host !== 'kiro') throw new HarnessError('MCP config discovery currently supports --host kiro');
|
|
10
|
+
if (check(root).status !== 'ok') throw new HarnessError('Fix installation drift before syncing integrations');
|
|
11
|
+
// Explicit input overrides the standard project/user Kiro settings. Never copy values.
|
|
12
|
+
const names = from ? [path.resolve(from)] : [path.join(os.homedir(), '.kiro/settings/mcp.json'), path.join(root,'.kiro/settings/mcp.json')];
|
|
13
|
+
const servers = new Set(); let found = false;
|
|
14
|
+
for (const filename of names) {
|
|
15
|
+
for (let p = filename; ; p = path.dirname(p)) {
|
|
16
|
+
if (stat(p)?.isSymbolicLink()) throw new HarnessError('Symlinked MCP config is unsupported');
|
|
17
|
+
if (path.dirname(p) === p) break;
|
|
18
|
+
}
|
|
19
|
+
const info = stat(filename);
|
|
20
|
+
if (!info) { if (from) throw new HarnessError('MCP config not found'); continue; }
|
|
21
|
+
if (!info.isFile() || info.size > 1024 * 1024) throw new HarnessError('MCP config must be a file up to 1 MiB');
|
|
22
|
+
let config;
|
|
23
|
+
try { config = JSON.parse(fs.readFileSync(filename,'utf8')); } catch { throw new HarnessError('Invalid MCP config JSON (values omitted)'); }
|
|
24
|
+
if (!isObject(config) || !isObject(config.mcpServers)) throw new HarnessError('MCP config requires mcpServers');
|
|
25
|
+
found = true;
|
|
26
|
+
for (const [binding, server] of Object.entries(config.mcpServers)) {
|
|
27
|
+
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(binding) || !isObject(server)) throw new HarnessError('Invalid MCP server declaration');
|
|
28
|
+
if (server.disabled === true) servers.delete(binding); else servers.add(binding);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!found) return { status: 'planned', discovered: [], added: [], note: 'No Kiro MCP config found. Verify active host connections directly.' };
|
|
32
|
+
if (servers.size > 128) throw new HarnessError('MCP inventory exceeds 128 servers');
|
|
33
|
+
return guard(root, () => {
|
|
34
|
+
const integrations = structuredClone(loadArtifactContract(root).integrations), added = [];
|
|
35
|
+
for (const binding of [...servers].sort()) {
|
|
36
|
+
if (Object.values(integrations.connections).some(c => c.via === 'mcp' && c.binding === binding)) continue;
|
|
37
|
+
const base = binding.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
|
|
38
|
+
let id = /^[a-z]/.test(base) ? base : 'mcp-' + base;
|
|
39
|
+
if (!SLUG.test(id)) throw new HarnessError('Cannot create a safe MCP alias');
|
|
40
|
+
let suffix = 2; const initial = id;
|
|
41
|
+
while (Object.hasOwn(integrations.connections,id)) id = initial + '-' + suffix++;
|
|
42
|
+
integrations.connections[id] = { via: 'mcp', binding, purpose: 'Discovered Kiro server; verify purpose, scope and actual tools in the host.', access: 'read-only' };
|
|
43
|
+
added.push(id);
|
|
44
|
+
}
|
|
45
|
+
if (!dryRun && added.length) transaction(root, { [INTEGRATIONS]: encode(integrations) });
|
|
46
|
+
return { status: dryRun ? 'planned' : 'ok', discovered: [...servers].sort(), added, availability: 'unverified', tools: 'host-discovery-required',
|
|
47
|
+
note: 'Aliases only; no credentials, endpoints, commands, env or autoApprove lists copied. Existing declarations preserved; absent servers are not deleted. Declared read-only access does not constrain the server.' };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { reviewJourney } from './journey-evidence.js';
|
|
2
3
|
import { isDeepStrictEqual } from 'node:util';
|
|
3
4
|
import { safe, stat, digest, isObject, HarnessError } from './files.js';
|
|
4
5
|
import { readConfig } from './project-config.js';
|
|
@@ -89,6 +90,8 @@ export function reviewVerification(root, reportPath) {
|
|
|
89
90
|
const reqs = new Map();
|
|
90
91
|
for (const req of report.requirements) {
|
|
91
92
|
assert(isObject(req) && text(req.id) && !reqs.has(req.id) && text(req.description) && text(req.source), 'Invalid/duplicate requirement');
|
|
93
|
+
assert(req.verification_target === undefined || ['behavior', 'end-user'].includes(req.verification_target), 'Unknown requirement verification target');
|
|
94
|
+
assert(req.verification_target !== 'end-user' || ['web', 'mobile', 'cli', 'api'].includes(req.surface), 'End-user requirement needs an interface surface');
|
|
92
95
|
reqs.set(req.id, req);
|
|
93
96
|
}
|
|
94
97
|
assert(Array.isArray(report.scenarios) && report.scenarios.length > 0, 'Declare scenarios, including NOT_RUN/BLOCKED gaps');
|
|
@@ -123,17 +126,18 @@ export function reviewVerification(root, reportPath) {
|
|
|
123
126
|
assert(refs.length > 0, 'Executed scenarios need digest-checked evidence');
|
|
124
127
|
if (ex.duration_ms !== undefined) assert(finite(ex.duration_ms), 'Invalid observed duration');
|
|
125
128
|
const permittedError = !row.error || (row.expected_error === row.error.kind && ['http', 'application'].includes(row.error.kind));
|
|
126
|
-
const semantic = !['api', 'backend-e2e', 'business-workflow', 'authorization', 'security'].includes(row.method) || assertions.some(a => a.layer !== 'transport');
|
|
129
|
+
const semantic = !['api', 'backend-e2e', 'business-workflow', 'authorization', 'security', 'browser-e2e', 'mobile-e2e', 'cli-e2e', 'end-user'].includes(row.method) || assertions.some(a => a.layer !== 'transport');
|
|
127
130
|
const pass = semantic && ex.exit_code === 0 && assertions.length > 0 && assertions.every(a => a.passed) && permittedError &&
|
|
128
131
|
(!row.mutates || row.cleanup.status === 'PASSED');
|
|
129
132
|
// Trust no typed PASS: recompute assertion/execution/cleanup consistency.
|
|
130
133
|
assert(row.status === (pass ? 'PASSED' : 'FAILED'), 'Claimed status contradicts assertions, execution, error or cleanup');
|
|
131
134
|
}
|
|
132
|
-
return { ...row, assertions, evidence: refs };
|
|
135
|
+
return { ...row, assertions, evidence: refs, ...reviewJourney(root, row) };
|
|
133
136
|
});
|
|
134
137
|
const requirements = [...reqs.values()].map(req => {
|
|
135
138
|
const linked = scenarios.filter(s => s.references.includes(req.id));
|
|
136
|
-
return { ...req, verification: linked.length > 0 && linked.every(s => s.status === 'PASSED' && s.assertions.some(a => a.basis === req.id && a.passed))
|
|
139
|
+
return { ...req, verification: linked.length > 0 && linked.every(s => s.status === 'PASSED' && s.assertions.some(a => a.basis === req.id && a.passed))
|
|
140
|
+
&& (req.verification_target !== 'end-user' || linked.some(s => s.status === 'PASSED' && s.acceptance_scope === 'end-user' && s.journey.surface === req.surface && s.assertions.some(a => a.basis === req.id && a.layer !== 'transport' && a.passed))) ? 'VERIFIED' : 'UNVERIFIED',
|
|
137
141
|
scenarios: linked.map(s => s.id) };
|
|
138
142
|
});
|
|
139
143
|
const counts = Object.fromEntries(statuses.map(s => [s, scenarios.filter(row => row.status === s).length]));
|
|
@@ -164,9 +168,9 @@ export function techspecEvidence(summary) {
|
|
|
164
168
|
...summary.requirements.map(r => '| ' + cell(r.id) + ' — ' + cell(r.description) + ' | ' + cell(r.source) + ' | ' + r.verification + ' | ' + cell(r.scenarios.join(', ')) + ' |'),
|
|
165
169
|
'',
|
|
166
170
|
'## Scenario results',
|
|
167
|
-
'| Scenario | Method | Status | Expected | Observed | Evidence |',
|
|
171
|
+
'| Scenario | Method / acceptance scope | Status | Expected | Observed | Evidence |',
|
|
168
172
|
'| --- | --- | --- | --- | --- | --- |',
|
|
169
|
-
...summary.scenarios.map(s => '| ' + cell(s.id) + ' | ' + cell(s.method) + ' | ' + s.status + ' | ' + cell(s.expected) + ' | ' + cell(s.observed) + ' | ' + cell(s.evidence.map(e => e.path + ' sha256:' + e.sha256).join(', ')) + ' |'),
|
|
173
|
+
...summary.scenarios.map(s => '| ' + cell(s.id) + ' | ' + cell(s.method + ' / ' + s.acceptance_scope) + ' | ' + s.status + ' | ' + cell(s.expected) + ' | ' + cell(s.observed) + ' | ' + cell(s.evidence.map(e => e.path + ' sha256:' + e.sha256).join(', ')) + ' |'),
|
|
170
174
|
'',
|
|
171
175
|
'## Limitations and change maintenance',
|
|
172
176
|
'Report validation checks consistency, not independent execution authenticity or business approval.',
|