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