@manhd_d/ai-harness 0.14.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 +43 -0
- package/README.md +21 -9
- 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/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 +53 -11
- package/cli/journey-evidence.js +34 -0
- package/cli/kiro.js +15 -0
- package/cli/knowledge.js +104 -0
- package/cli/main.js +60 -22
- package/cli/portable-config.js +6 -3
- 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.js +15 -2
- 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 +75 -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 +13 -2
- 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 +8 -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 +43 -3
- package/docs/cli.md +39 -4
- package/docs/configuration-backup.md +10 -2
- 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 +17 -5
- package/docs/harness-maintenance.md +1 -0
- package/docs/i18n/README.es.md +17 -7
- package/docs/i18n/README.vi.md +18 -8
- package/docs/i18n/README.zh-CN.md +17 -7
- package/docs/project-configuration.md +6 -0
- 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 +7 -2
- 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 +56 -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 -1
- package/templates/decisions.json +4 -0
- package/templates/local.md +21 -0
- package/templates/openspec/approvals.json +5 -0
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,17 +1,21 @@
|
|
|
1
|
-
import {
|
|
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';
|
|
2
6
|
import { backupConfig, restoreConfig } from './portable-config.js';
|
|
3
7
|
import { listAdapters, detect, verify, evaluate } from './adapters.js';
|
|
4
8
|
import { listCapabilities, skillCatalog, readSkill } from './capabilities.js';
|
|
5
9
|
import { resolveModules } from './composition.js';
|
|
6
10
|
import { parseArgs } from 'node:util';
|
|
7
|
-
import { setupPlan } from './environment.js';
|
|
8
11
|
import { ready } from './readiness.js';
|
|
12
|
+
import { workflowReport } from './workflow.js';
|
|
9
13
|
import { resumePlan } from './checkpoint.js';
|
|
10
14
|
import { agentPlan } from './agents.js';
|
|
11
15
|
import { adaptiveVerification } from './verification.js';
|
|
12
16
|
import { reviewVerification } from './verification-report.js';
|
|
13
17
|
import { artifactReport } from './artifacts.js';
|
|
14
|
-
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';
|
|
15
19
|
import path from 'node:path';
|
|
16
20
|
|
|
17
21
|
const HELP = `aih — install reusable AI development rules into a project
|
|
@@ -31,16 +35,27 @@ Usage: aih <command> [options]
|
|
|
31
35
|
update Apply the installed CLI bundle to the project (no download)
|
|
32
36
|
check Check Harness file integrity, not application tests
|
|
33
37
|
doctor Check installation, configured paths and tool presence
|
|
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
|
|
34
41
|
start Guide intake; reuse saved answers and hand off to your AI host
|
|
35
42
|
backup Export user configuration (not docs/checkpoints/secrets)
|
|
36
43
|
restore Restore --from backup; --transfer reuses modules/model preferences
|
|
37
|
-
setup Suggest
|
|
44
|
+
setup Suggest questions; --apply scaffolds missing user config only
|
|
45
|
+
decisions Read shared decisions; --scope module:<name> narrows context
|
|
38
46
|
artifacts Show input/output/storage and connection declarations (read-only)
|
|
47
|
+
workflow validate Validate installed workflow contracts (read-only)
|
|
39
48
|
ready <id> Check structural change readiness, not approval
|
|
40
49
|
new-change <id> Create an OpenSpec draft using installed templates
|
|
41
50
|
new-doc <type> Scaffold a locked capability template (default: product)
|
|
42
51
|
|
|
43
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
|
|
44
59
|
--goal <type> start: idea, docs, change, resume (saves with --task)
|
|
45
60
|
--task <text> start: task description; --change supplies resume checkpoint ID
|
|
46
61
|
--input <path/url> start: seed requirements only if no inputs declared
|
|
@@ -50,12 +65,13 @@ Options:
|
|
|
50
65
|
--replace restore: explicit replacement; automatic before-backup
|
|
51
66
|
--target <directory> Project directory (default: current directory)
|
|
52
67
|
--detect init only: opt in to detected stack selection
|
|
53
|
-
--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
|
|
54
70
|
--capability <id> new-doc only: installed template owner (default: product)
|
|
55
71
|
--agent-preset <id> init only: recommended (default), economy, custom; no overwrite
|
|
56
72
|
--link-claude init/update: opt in to a managed CLAUDE.md import
|
|
57
73
|
--claude-skills init/update: opt in to canonical .claude/skills wrappers
|
|
58
|
-
--host <id> init/setup/agents: codex,claude-code,gemini-cli,cursor,copilot or custom host ID
|
|
74
|
+
--host <id> init/update/setup/agents/start: kiro,codex,claude-code,gemini-cli,cursor,copilot or custom host ID
|
|
59
75
|
--scope <type> verify/eval: adapters (default), capabilities, core, all
|
|
60
76
|
--adaptive verify: discover a read-only project verification plan
|
|
61
77
|
--expected <sha> checkpoint: observed prior checkpoint digest for replacement
|
|
@@ -63,12 +79,13 @@ Options:
|
|
|
63
79
|
--adapters <a,b> init/resolve: java-spring,react-nextjs,react-native,ai-python,kubernetes,astro
|
|
64
80
|
--capabilities <a,b> init/resolve: uiux,product,architecture,delivery,devops,monetization
|
|
65
81
|
--source <directory> init/update/list/detect/resolve: trusted alternate bundle directory
|
|
66
|
-
--dry-run init/update: preview without persistent changes
|
|
82
|
+
--dry-run init/update/setup --apply: preview without persistent changes
|
|
67
83
|
--allow-major init/update: acknowledge a major version migration
|
|
68
84
|
-h, --help Show help
|
|
69
85
|
-v, --version Show installed CLI version
|
|
70
86
|
|
|
71
|
-
|
|
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.
|
|
72
89
|
User settings: .harness/; backup exports only documented user files.
|
|
73
90
|
Restore config before init on a fresh project; --transfer excludes old sources/tasks.
|
|
74
91
|
The host runs application tests; verify prints plans or validates supplied evidence.
|
|
@@ -109,8 +126,8 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
109
126
|
let args;
|
|
110
127
|
try {
|
|
111
128
|
args = parseArgs({ args: argv, allowPositionals: true, strict: true, tokens: true, options: {
|
|
112
|
-
goal: { type: 'string' }, task: { type: 'string' }, change: { type: 'string' }, input: { type: 'string' }, output: { type: 'string' }, from: { type: 'string' }, transfer: { type: 'boolean' }, replace: { type: 'boolean' }, target: { type: 'string' }, adapters: { type: 'string' }, capabilities: { type: 'string' }, capability: { type: 'string' }, source: { type: 'string' },
|
|
113
|
-
'agent-preset': { type: 'string' }, host: { type: 'string' }, expected: { type: 'string' },
|
|
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' },
|
|
114
131
|
'link-claude': { type: 'boolean' }, 'claude-skills': { type: 'boolean' }, adaptive: { type: 'boolean' }, detect: { type: 'boolean' }, report: { type: 'string' }, scope: { type: 'string' }, stage: { type: 'string' },
|
|
115
132
|
'dry-run': { type: 'boolean' }, 'allow-major': { type: 'boolean' },
|
|
116
133
|
help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
|
|
@@ -123,10 +140,23 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
123
140
|
if (args.values.help || !argv.length) { console.log(HELP); return 0; }
|
|
124
141
|
if (args.values.version) { console.log(readJson(path.join(SOURCE, 'package.json')).version); return 0; }
|
|
125
142
|
const [command, name, ...extra] = args.positionals;
|
|
126
|
-
if (!['start', 'backup', 'restore', 'init', 'update', 'check', 'doctor', 'new-change', 'new-doc', 'list', 'detect', 'resolve', 'verify', 'eval', 'skills', 'skill', 'setup', 'ready', 'artifacts', 'agents', 'checkpoint', 'resume'].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
127
|
-
if (extra.length || (!['new-change', 'new-doc', 'skill', 'ready', 'checkpoint', 'resume'].includes(command) && name !== undefined) || (['new-change', 'new-doc', 'skill', 'ready', 'checkpoint', 'resume'].includes(command) && name === undefined)) throw new Error('Unexpected or missing positional argument; run aih --help');
|
|
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');
|
|
128
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'] : [])]);
|
|
129
|
-
if (command === '
|
|
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
|
+
}
|
|
130
160
|
if (command === 'backup') allowed.add('output');
|
|
131
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'); }
|
|
132
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');
|
|
@@ -136,7 +166,7 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
136
166
|
if (args.values.adaptive && args.values.report) throw new Error('Choose --adaptive or --report');
|
|
137
167
|
if (args.values.scope && (args.values.adaptive || (command === 'verify' && args.values.report))) throw new Error('--scope applies only to advisory checks');
|
|
138
168
|
if (command === 'init') allowed.add('agent-preset');
|
|
139
|
-
if (['setup','agents','init'].includes(command)) allowed.add('host');
|
|
169
|
+
if (['setup','agents','init','update'].includes(command)) allowed.add('host');
|
|
140
170
|
if (['init','update'].includes(command)) { allowed.add('link-claude'); allowed.add('claude-skills'); }
|
|
141
171
|
for (const key of Object.keys(args.values)) if (!allowed.has(key)) throw new Error(`--${key} is not supported for ${command}`);
|
|
142
172
|
if (args.values.detect && args.values.adapters !== undefined) throw new Error('Choose either --detect or --adapters');
|
|
@@ -146,10 +176,18 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
146
176
|
const { values, positionals: [command, name] } = args;
|
|
147
177
|
const root = targetRoot(values.target ?? process.cwd());
|
|
148
178
|
let result;
|
|
149
|
-
if (command === '
|
|
150
|
-
if (
|
|
151
|
-
result =
|
|
152
|
-
} else 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);
|
|
153
191
|
else if (command === 'restore') result = restoreConfig(root, values.from, { dryRun: values['dry-run'], transfer: values.transfer, replace: values.replace });
|
|
154
192
|
else if (command === 'init' || command === 'update') {
|
|
155
193
|
const source = targetRoot(values.source ?? SOURCE);
|
|
@@ -185,17 +223,17 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
185
223
|
if (check(root).status !== 'ok') throw new Error('Fix installation drift before reading artifact declarations');
|
|
186
224
|
result = artifactReport(root);
|
|
187
225
|
}
|
|
188
|
-
else if (command === '
|
|
226
|
+
else if (command === 'ready') {
|
|
189
227
|
const state = check(root);
|
|
190
228
|
if (state.status !== 'ok') throw new Error('Fix installation drift before setup/readiness checks');
|
|
191
|
-
result =
|
|
229
|
+
result = ready(root, name, values.stage ?? 'implement', values.workflow);
|
|
192
230
|
}
|
|
193
231
|
else if (command === 'new-change') result = newChange(root, name);
|
|
194
232
|
else if (command === 'new-doc') result = newDoc(root, name, values.capability ?? 'product', { report: values.report });
|
|
195
|
-
else result = command === 'doctor' ? doctor(root) : check(root);
|
|
233
|
+
else result = command === 'doctor' ? doctor(root, values.skill) : check(root);
|
|
196
234
|
if (command === 'init' || command === 'update') result.next_steps = values['dry-run']
|
|
197
235
|
? ['Review the proposed changes, then repeat without --dry-run.']
|
|
198
|
-
: ['Run start with the same CLI (aih start or npx --yes @manhd_d/ai-harness start); open your AI host and send host_prompt.',
|
|
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.',
|
|
199
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.',
|
|
200
238
|
'Run doctor and review/commit shared configuration and snapshot. Use backup; copy its file outside the repo. See docs/configuration-backup.md.'];
|
|
201
239
|
if (command === 'new-change') result.next_steps = [
|
package/cli/portable-config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { localContext } from './knowledge.js';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import os from 'node:os';
|
|
@@ -9,9 +10,10 @@ import { loadAgentConfig } from './agents.js';
|
|
|
9
10
|
import { loadArtifactContract } from './artifacts.js';
|
|
10
11
|
import { loadStartConfig } from './start-config.js';
|
|
11
12
|
import { resolveModules } from './composition.js';
|
|
13
|
+
import { PROJECT_DECISIONS, loadProjectDecisions } from './project-decisions.js';
|
|
12
14
|
|
|
13
15
|
export const USER_FILES = ['.harness/project.json', '.harness/agents.json', '.harness/artifacts.json',
|
|
14
|
-
'.harness/integrations.json', '.harness/environment.json', '.harness/local.md', '.harness/start.json', 'openspec/config.yaml'];
|
|
16
|
+
'.harness/integrations.json', '.harness/environment.json', '.harness/local.md', '.harness/start.json', PROJECT_DECISIONS, 'openspec/config.yaml'];
|
|
15
17
|
const MAX_FILE = 1024 * 1024, MAX_ARCHIVE = 12 * MAX_FILE;
|
|
16
18
|
const fail = (ok, message) => { if (!ok) throw new HarnessError(message); };
|
|
17
19
|
export const backupName = () => '.harness/backups/config-' + new Date().toISOString().replace(/[:.]/g, '-') + '-' + randomUUID() + '.json';
|
|
@@ -53,7 +55,7 @@ function validateFiles(files) {
|
|
|
53
55
|
}
|
|
54
56
|
const config = projectConfig(temp, undefined, undefined, true);
|
|
55
57
|
resolveModules(SOURCE, config);
|
|
56
|
-
loadAgentConfig(temp); loadArtifactContract(temp); loadEnvironment(temp); loadStartConfig(temp);
|
|
58
|
+
loadAgentConfig(temp); loadArtifactContract(temp); loadEnvironment(temp); loadStartConfig(temp); localContext(temp); loadProjectDecisions(temp);
|
|
57
59
|
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
|
58
60
|
}
|
|
59
61
|
function outputPath(root, name) {
|
|
@@ -79,7 +81,8 @@ export function restoreConfig(root, from, { dryRun = false, transfer = false, re
|
|
|
79
81
|
const files = transfer ? Object.fromEntries(Object.entries(archive.files).filter(([name]) => ['.harness/project.json', '.harness/agents.json'].includes(name))) : archive.files;
|
|
80
82
|
if (transfer && files['.harness/agents.json']) {
|
|
81
83
|
const config = JSON.parse(files['.harness/agents.json'].content);
|
|
82
|
-
delete config.execution.autonomy; // Repository-scoped
|
|
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;
|
|
83
86
|
files['.harness/agents.json'] = { content: encode(config).toString() };
|
|
84
87
|
}
|
|
85
88
|
const changes = {}, conflicts = [];
|
|
@@ -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
|
+
}
|
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
|
+
}
|