@ericrisco/rsc 1.2.3 → 1.3.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.
@@ -0,0 +1,206 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, writeFileSync } from 'node:fs';
3
+ import { join, relative, resolve, sep } from 'node:path';
4
+ import { applyInstall, pruneSharedBases, removeTargetInstall } from '../install-apply.js';
5
+ import { targetPaths } from '../../targets/index.js';
6
+ import { readState } from './state.js';
7
+ import { readManifest, writeManifest } from './manifest-file.js';
8
+ import { encodeGoal, identifyPlan } from './onboarding.js';
9
+ import { createBackup, restoreBackup } from './backups.js';
10
+
11
+ const sameSet = (a = [], b = []) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
12
+
13
+ function inside(root, path) {
14
+ return path === root || path.startsWith(`${root}${sep}`);
15
+ }
16
+
17
+ export function assertManagedPathsStayInsideRoot(cwd, paths) {
18
+ const root = realpathSync(resolve(cwd));
19
+ for (const rel of paths || []) {
20
+ const clean = rel.replace(/\/$/, '');
21
+ const absolute = resolve(root, clean);
22
+ if (!inside(root, absolute)) throw new Error(`RSC_ROOT_AMBIGUOUS: managed path escapes project root: ${rel}`);
23
+ const segments = relative(root, absolute).split(sep).filter(Boolean);
24
+ let cursor = root;
25
+ for (const segment of segments) {
26
+ cursor = join(cursor, segment);
27
+ if (!existsSync(cursor)) break;
28
+ if (lstatSync(cursor).isSymbolicLink()) {
29
+ const destination = realpathSync(cursor);
30
+ if (!inside(root, destination)) throw new Error(`RSC_ROOT_AMBIGUOUS: managed path follows a symlink outside project root: ${rel}`);
31
+ }
32
+ }
33
+ }
34
+ }
35
+
36
+ function digestPath(path) {
37
+ if (!existsSync(path)) return 'missing';
38
+ const stat = lstatSync(path);
39
+ if (stat.isSymbolicLink()) return createHash('sha256').update(`link:${readlinkSync(path)}`).digest('hex');
40
+ if (stat.isDirectory()) {
41
+ const rows = readdirSync(path).sort().map((name) => `${name}:${digestPath(join(path, name))}`);
42
+ return createHash('sha256').update(`dir:${rows.join('|')}`).digest('hex');
43
+ }
44
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
45
+ }
46
+
47
+ function digestGovernedPaths(cwd, paths) {
48
+ return Object.fromEntries((paths || [])
49
+ .filter((path) => path !== '.rsc.json' && path !== '.rsc/backups/')
50
+ .map((path) => [path, digestPath(join(cwd, path.replace(/\/$/, '')))]));
51
+ }
52
+
53
+ function recoveryCommand(plan, planId) {
54
+ const record = plan.record;
55
+ return [
56
+ 'npx @ericrisco/rsc@latest onboard',
57
+ `--technical-level ${record.technicalLevel}`,
58
+ `--accompaniment ${record.accompaniment}`,
59
+ `--project-kind ${record.projectKind}`,
60
+ `--goal-base64 ${encodeGoal(record.goal)}`,
61
+ ...(record.softwareScope ? [`--software-scope ${record.softwareScope}`] : []),
62
+ `--target ${record.targets.join(',')}`,
63
+ `--accept-plan ${planId}`,
64
+ ].join(' ');
65
+ }
66
+
67
+ export function renderOnboardingDocuments(plan, planId) {
68
+ const profile = `---\ntechnical_level: ${plan.record.technicalLevel}\naccompaniment: ${plan.record.accompaniment}\nproject_kind: ${plan.record.projectKind}\n---\n\n# User profile\n\nGoal: ${plan.record.goal}\n`;
69
+ const rows = plan.decisions.map((d) => `| ${d.kind} | ${d.id} | ${d.state} | ${d.reason} | ${d.reevaluateWhen.join('; ') || '—'} |`).join('\n');
70
+ const installation = `# Accepted harness plan\n\nPlan id: \`${planId}\`\n\n| Kind | Component | Decision | Reason | Reevaluate when |\n| --- | --- | --- | --- | --- |\n${rows}\n`;
71
+ const decisions = `# Harness decisions\n\n- Accepted plan \`${planId}\`.\n- Project kind: ${plan.record.projectKind}.\n- SDD: ${plan.decisions.find((d) => d.id === 'sdd')?.state || 'selected through profile'}.\n`;
72
+ return { profile, installation, decisions };
73
+ }
74
+
75
+ export function writeOnboardingDocuments(cwd, plan, planId) {
76
+ const dir = join(cwd, '02-DOCS', 'wiki', 'harness');
77
+ mkdirSync(dir, { recursive: true });
78
+ const docs = renderOnboardingDocuments(plan, planId);
79
+ writeFileSync(join(dir, 'user-profile.md'), docs.profile);
80
+ writeFileSync(join(dir, 'installation-plan.md'), docs.installation);
81
+ const decisionsPath = join(dir, 'decisions.md');
82
+ if (!existsSync(decisionsPath)) writeFileSync(decisionsPath, docs.decisions);
83
+ else if (!readFileSync(decisionsPath, 'utf8').includes(`Accepted plan \`${planId}\``)) {
84
+ appendFileSync(decisionsPath, `${readFileSync(decisionsPath, 'utf8').endsWith('\n') ? '' : '\n'}\n${docs.decisions.replace(/^# Harness decisions\n+/, '')}`);
85
+ }
86
+ }
87
+
88
+ export function verifyOnboarding(cwd, plan, planId) {
89
+ const differences = [];
90
+ if (identifyPlan(plan) !== planId) differences.push('persisted plan identity differs from its canonical content');
91
+ for (const target of plan.policy.targets) {
92
+ const state = readState(targetPaths(target, undefined, cwd).stateFile);
93
+ if (!sameSet(Object.keys(state.skills || {}), plan.policy.skills)) differences.push(`${target}: installed skills differ from accepted policy`);
94
+ if (!sameSet(state.agents || [], plan.policy.agents || [])) differences.push(`${target}: installed agents differ from accepted policy`);
95
+ if (state.policy?.alwaysOn !== plan.policy.alwaysOn) differences.push(`${target}: always-on policy differs`);
96
+ if (state.policy?.codeHooks !== plan.policy.codeHooks) differences.push(`${target}: code-hook policy differs`);
97
+ if (state.policy?.memory !== plan.policy.memory) differences.push(`${target}: memory policy differs`);
98
+ if (state.policy?.context7 !== plan.policy.context7) differences.push(`${target}: context7 policy differs`);
99
+ }
100
+ for (const name of ['user-profile.md', 'installation-plan.md', 'decisions.md']) {
101
+ if (!existsSync(join(cwd, '02-DOCS', 'wiki', 'harness', name))) differences.push(`missing ${name}`);
102
+ }
103
+ for (const path of plan.governedPaths || []) {
104
+ if (!existsSync(join(cwd, path.replace(/\/$/, '')))) differences.push(`missing governed path ${path}`);
105
+ }
106
+ const manifest = readManifest(cwd);
107
+ if (manifest?.onboarding?.acceptedPlanId !== planId) differences.push('manifest receipt differs from accepted plan');
108
+ const expectedDigests = manifest?.onboarding?.artifactDigests;
109
+ if (!expectedDigests) differences.push('manifest receipt has no governed artifact digests');
110
+ else {
111
+ const expectedPaths = (plan.governedPaths || []).filter((path) => path !== '.rsc.json' && path !== '.rsc/backups/');
112
+ if (!sameSet(Object.keys(expectedDigests), expectedPaths)) differences.push('governed artifact digest inventory differs from accepted plan');
113
+ for (const path of expectedPaths) {
114
+ if (digestPath(join(cwd, path.replace(/\/$/, ''))) !== expectedDigests[path]) differences.push(`governed content differs at ${path}`);
115
+ }
116
+ }
117
+ const docsDir = join(cwd, '02-DOCS', 'wiki', 'harness');
118
+ const profile = existsSync(join(docsDir, 'user-profile.md')) ? readFileSync(join(docsDir, 'user-profile.md'), 'utf8') : '';
119
+ for (const line of [
120
+ `technical_level: ${plan.record.technicalLevel}`,
121
+ `accompaniment: ${plan.record.accompaniment}`,
122
+ `project_kind: ${plan.record.projectKind}`,
123
+ `Goal: ${plan.record.goal}`,
124
+ ]) if (!profile.split('\n').includes(line)) differences.push(`profile content differs: ${line.split(':')[0]}`);
125
+ const installation = existsSync(join(docsDir, 'installation-plan.md')) ? readFileSync(join(docsDir, 'installation-plan.md'), 'utf8') : '';
126
+ if (!installation.includes(`Plan id: \`${planId}\``)) differences.push('installation plan identity differs');
127
+ for (const decision of plan.decisions || []) {
128
+ const row = `| ${decision.kind} | ${decision.id} | ${decision.state} | ${decision.reason} | ${decision.reevaluateWhen.join('; ') || '—'} |`;
129
+ if (!installation.split('\n').includes(row)) differences.push(`installation plan content differs for ${decision.kind}/${decision.id}`);
130
+ }
131
+ const decisions = existsSync(join(docsDir, 'decisions.md')) ? readFileSync(join(docsDir, 'decisions.md'), 'utf8') : '';
132
+ if (!decisions.includes(`Accepted plan \`${planId}\``)) differences.push('decision ledger omits accepted plan');
133
+ if (!sameSet(manifest?.targets || [], plan.policy.targets)) differences.push('manifest targets differ from accepted policy');
134
+ if (!sameSet(manifest?.skills || [], plan.policy.skills)) differences.push('manifest skills differ from accepted policy');
135
+ if (!sameSet(manifest?.agents || [], plan.policy.agents || [])) differences.push('manifest agents differ from accepted policy');
136
+ return differences;
137
+ }
138
+
139
+ export async function applyAcceptedOnboarding({ cwd = process.cwd(), plan, planId, now = new Date(), apply = applyInstall }) {
140
+ if (identifyPlan(plan) !== planId) throw new Error('RSC_PLAN_CHANGED: regenerate the plan and ask the user to accept the new id');
141
+ const previousManifest = readManifest(cwd);
142
+ const previousReceipt = previousManifest?.onboarding;
143
+ const previousPlan = previousReceipt?.plan;
144
+ const transactionPaths = [...new Set([
145
+ ...(plan.governedPaths || []),
146
+ ...(previousPlan?.governedPaths || []),
147
+ '.rsc.json',
148
+ ])];
149
+ assertManagedPathsStayInsideRoot(cwd, transactionPaths);
150
+ const snapshotPaths = transactionPaths.filter((path) => path !== '.rsc/backups/');
151
+ const transaction = createBackup({
152
+ cwd, operation: 'onboarding-transaction', target: plan.policy.targets.join('-'),
153
+ paths: snapshotPaths.map((path) => join(cwd, path.replace(/\/$/, ''))), cliVersion: 'onboarding', now,
154
+ });
155
+ const existed = Object.fromEntries((plan.governedPaths || []).map((path) => [path, existsSync(join(cwd, path.replace(/\/$/, '')))]));
156
+ const receipt = {
157
+ schemaVersion: 1,
158
+ acceptedPlanId: planId,
159
+ acceptedAt: now.toISOString(),
160
+ plan,
161
+ provenance: {
162
+ skills: Object.fromEntries(plan.policy.skills.map((id) => [id,
163
+ previousReceipt?.plan?.policy?.skills?.includes(id) ? 'conserved' : previousManifest?.skills?.includes(id) ? 'preexisting' : 'installed',
164
+ ])),
165
+ targets: Object.fromEntries(plan.policy.targets.map((id) => [id,
166
+ previousReceipt?.plan?.policy?.targets?.includes(id) ? 'conserved' : previousManifest?.targets?.includes(id) ? 'preexisting' : 'installed',
167
+ ])),
168
+ paths: {},
169
+ },
170
+ };
171
+ try {
172
+ for (const target of previousPlan?.policy?.targets || []) {
173
+ if (!plan.policy.targets.includes(target)) removeTargetInstall({ cwd, target });
174
+ }
175
+ for (const target of plan.policy.targets) {
176
+ await apply({
177
+ cwd,
178
+ target,
179
+ skillIds: plan.policy.skills,
180
+ policy: plan.policy,
181
+ operation: 'onboard',
182
+ });
183
+ }
184
+ pruneSharedBases({ cwd, skillIds: plan.policy.skills });
185
+ writeOnboardingDocuments(cwd, plan, planId);
186
+ receipt.provenance.paths = Object.fromEntries((plan.governedPaths || []).map((path) => [
187
+ path,
188
+ existed[path] ? (previousReceipt ? 'conserved' : 'preexisting') : 'installed',
189
+ ]));
190
+ receipt.artifactDigests = digestGovernedPaths(cwd, plan.governedPaths);
191
+ const manifest = readManifest(cwd);
192
+ writeManifest(cwd, {
193
+ ...manifest,
194
+ targets: [...plan.policy.targets],
195
+ skills: [...plan.policy.skills],
196
+ agents: [...(plan.policy.agents || [])],
197
+ onboarding: receipt,
198
+ });
199
+ const differences = verifyOnboarding(cwd, plan, planId);
200
+ if (differences.length) throw new Error(differences.join('; '));
201
+ return receipt;
202
+ } catch (error) {
203
+ restoreBackup({ cwd, id: transaction.id });
204
+ throw new Error(`RSC_ONBOARDING_INCOMPLETE: ${error.message}. Recover with: ${recoveryCommand(plan, planId)}`);
205
+ }
206
+ }
@@ -0,0 +1,301 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { dirname, join, relative, resolve, sep } from 'node:path';
4
+ import { TARGET_IDS } from '../../targets/index.js';
5
+ import { resolveAgentNames } from '../../targets/agents.js';
6
+ import { loadManifest, skillsForProfile } from './manifest.js';
7
+ import { managedPathsForInstall } from '../install-apply.js';
8
+
9
+ export const ONBOARDING_SCHEMA_VERSION = 1;
10
+ export const ONBOARDING_VALUES = Object.freeze({
11
+ technicalLevel: ['non-technical', 'mixed', 'technical'],
12
+ accompaniment: ['L0', 'L1', 'L2', 'L3'],
13
+ projectKind: ['software', 'operations', 'research', 'content', 'mixed'],
14
+ softwareScope: ['small', 'growing', 'complex'],
15
+ });
16
+
17
+ const ignored = new Set([
18
+ '.git', '.rsc', 'node_modules', '.venv', '.next', 'dist', 'build', 'coverage', '__pycache__', '.dart_tool',
19
+ '.claude', '.codex', '.cursor', '.opencode', '.amp', '.jules', '.zed', '.gemini', '.antigravity',
20
+ '.windsurf', '.clinerules', '.roo', '.continue', '.junie', '.kiro', '.aider',
21
+ ]);
22
+ const manifests = new Set(['package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pubspec.yaml', 'requirements.txt']);
23
+ const sourceExt = new Set(['.js', '.jsx', '.ts', '.tsx', '.py', '.go', '.rs', '.dart', '.java', '.kt', '.swift', '.php', '.rb']);
24
+
25
+ const clean = (value) => String(value ?? '').trim();
26
+ const oneOf = (field, value, values) => {
27
+ if (!values.includes(value)) throw new Error(`invalid ${field}: expected ${values.join('|')}`);
28
+ return value;
29
+ };
30
+
31
+ export function normalizeOnboarding(raw = {}) {
32
+ const schemaVersion = Number(raw.schemaVersion ?? ONBOARDING_SCHEMA_VERSION);
33
+ if (schemaVersion !== ONBOARDING_SCHEMA_VERSION) throw new Error(`invalid schemaVersion: expected ${ONBOARDING_SCHEMA_VERSION}`);
34
+ const technicalLevel = oneOf('technical-level', clean(raw.technicalLevel).toLowerCase(), ONBOARDING_VALUES.technicalLevel);
35
+ const accompaniment = oneOf('accompaniment', clean(raw.accompaniment).toUpperCase(), ONBOARDING_VALUES.accompaniment);
36
+ const projectKind = oneOf('project-kind', clean(raw.projectKind).toLowerCase(), ONBOARDING_VALUES.projectKind);
37
+ const goal = clean(raw.goal).replace(/\s+/g, ' ');
38
+ if (!goal) throw new Error('invalid goal: a concrete goal is required');
39
+ const targets = [...new Set((Array.isArray(raw.targets) ? raw.targets : clean(raw.targets).split(','))
40
+ .map((target) => clean(target).toLowerCase()).filter(Boolean))].sort();
41
+ if (!targets.length || targets.some((target) => !TARGET_IDS.includes(target))) {
42
+ throw new Error(`invalid target: expected one or more of ${TARGET_IDS.join(',')}`);
43
+ }
44
+ const needsScope = projectKind === 'software' || projectKind === 'mixed';
45
+ const softwareScope = clean(raw.softwareScope).toLowerCase();
46
+ if (needsScope) oneOf('software-scope', softwareScope, ONBOARDING_VALUES.softwareScope);
47
+ return {
48
+ schemaVersion,
49
+ technicalLevel,
50
+ accompaniment,
51
+ projectKind,
52
+ goal,
53
+ ...(needsScope ? { softwareScope } : {}),
54
+ targets,
55
+ };
56
+ }
57
+
58
+ function extension(file) {
59
+ const dot = file.lastIndexOf('.');
60
+ return dot < 0 ? '' : file.slice(dot);
61
+ }
62
+
63
+ export function scanProject(root = process.cwd()) {
64
+ const absolute = realpathSync(resolve(root));
65
+ const signals = [];
66
+ const stacks = new Set();
67
+ const complexitySignals = new Set();
68
+ let markdownCount = 0;
69
+ let sourceFileCount = 0;
70
+ const visit = (dir) => {
71
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
72
+ if (ignored.has(entry.name) || entry.isSymbolicLink()) continue;
73
+ const path = join(dir, entry.name);
74
+ const rel = relative(absolute, path).split(sep).join('/');
75
+ if (rel.startsWith('../') || rel === '..') throw new Error('project scan escaped the selected root');
76
+ if (rel === '02-DOCS/wiki/harness' || rel.startsWith('02-DOCS/wiki/harness/')) continue;
77
+ if (entry.isDirectory()) { visit(path); continue; }
78
+ if (!entry.isFile()) continue;
79
+ let isSignal = manifests.has(entry.name) || entry.name.endsWith('.md');
80
+ // Codex stores the always-on layer in AGENTS.md. Ignore a file that contains
81
+ // only our managed block, while retaining a user's surrounding instructions
82
+ // as real project evidence across repair/re-onboarding.
83
+ if (entry.name === 'AGENTS.md') {
84
+ const human = readFileSync(path, 'utf8')
85
+ .replace(/\n*<!-- rsc-suggest:start -->[\s\S]*?<!-- rsc-suggest:end -->\n*/g, '')
86
+ .trim();
87
+ isSignal = Boolean(human);
88
+ }
89
+ if (isSignal && manifests.has(entry.name)) signals.push(`manifest:${entry.name}`);
90
+ else if (isSignal) markdownCount++;
91
+ if (sourceExt.has(extension(entry.name))) sourceFileCount++;
92
+ const lowerRel = rel.toLowerCase();
93
+ if (/(^|[/_.-])(auth|login|oauth|session)([/_.-]|$)/.test(lowerRel)) complexitySignals.add('authentication');
94
+ if (/(^|[/_.-])(payment|billing|checkout)([/_.-]|$)/.test(lowerRel)) complexitySignals.add('payments');
95
+ if (/(^|[/_.-])(database|persistence|migration|schema)([/_.-]|$)/.test(lowerRel)) complexitySignals.add('persistence');
96
+ if (/(^|[/_.-])(integration|webhook|connector)([/_.-]|$)/.test(lowerRel)) complexitySignals.add('external-integrations');
97
+ if (entry.name === 'package.json') {
98
+ try {
99
+ const pkg = JSON.parse(readFileSync(path, 'utf8'));
100
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
101
+ for (const [name, stack] of [['next', 'nextjs'], ['react', 'react'], ['vue', 'vue'], ['svelte', 'svelte']]) if (deps[name]) stacks.add(stack);
102
+ stacks.add('node');
103
+ const names = Object.keys(deps).join(' ').toLowerCase();
104
+ if (/(passport|auth0|clerk|next-auth|better-auth)/.test(names)) complexitySignals.add('authentication');
105
+ if (/(stripe|paypal|adyen|braintree)/.test(names)) complexitySignals.add('payments');
106
+ if (/(prisma|typeorm|sequelize|mongoose|postgres|mysql|sqlite|supabase|firebase)/.test(names)) complexitySignals.add('persistence');
107
+ if (/(webhook|octokit|twilio|sendgrid|slack)/.test(names)) complexitySignals.add('external-integrations');
108
+ } catch { stacks.add('node'); }
109
+ }
110
+ if (entry.name === 'pyproject.toml' || entry.name === 'requirements.txt') stacks.add('python');
111
+ if (entry.name === 'go.mod') stacks.add('go');
112
+ if (entry.name === 'Cargo.toml') stacks.add('rust');
113
+ if (entry.name === 'pubspec.yaml') stacks.add('flutter');
114
+ }
115
+ };
116
+ visit(absolute);
117
+ if (markdownCount) signals.push(`markdown:${markdownCount}`);
118
+ let parentHarness = null;
119
+ let cursor = dirname(absolute);
120
+ while (cursor !== dirname(cursor)) {
121
+ if (existsSync(join(cursor, '.rsc.json'))) {
122
+ parentHarness = relative(absolute, cursor).split(sep).join('/') || '..';
123
+ break;
124
+ }
125
+ cursor = dirname(cursor);
126
+ }
127
+ const evidence = {
128
+ schemaVersion: 1,
129
+ signals: [...new Set(signals)].sort(),
130
+ stacks: [...stacks].sort(),
131
+ complexitySignals: [...complexitySignals].sort(),
132
+ sourceFileCount,
133
+ parentHarness,
134
+ };
135
+ Object.defineProperty(evidence, 'root', { value: absolute, enumerable: false });
136
+ return evidence;
137
+ }
138
+
139
+ function selected(id, kind, reason, provenance = 'declared-intent') {
140
+ return { kind, id, state: 'selected', reason, provenance, reevaluateWhen: [] };
141
+ }
142
+ function deferred(id, kind, reason, reevaluateWhen, triggerRules = [], provenance = 'proportionality-policy') {
143
+ return { kind, id, state: 'deferred', reason, provenance, reevaluateWhen, triggerRules };
144
+ }
145
+ function excluded(id, kind, reason, provenance = 'separate-consent-boundary') {
146
+ return { kind, id, state: 'excluded', reason, provenance, reevaluateWhen: [], triggerRules: [] };
147
+ }
148
+
149
+ export function buildOnboardingPlan(record, evidence) {
150
+ const normalized = normalizeOnboarding(record);
151
+ const isSoftware = normalized.projectKind === 'software' || normalized.projectKind === 'mixed';
152
+ const goalSignals = [];
153
+ const goal = normalized.goal.toLowerCase();
154
+ if (/auth|login|oauth|sesión|session/.test(goal)) goalSignals.push('authentication');
155
+ if (/payment|billing|checkout|pago|cobro/.test(goal)) goalSignals.push('payments');
156
+ if (/database|persistence|persistencia|base de datos/.test(goal)) goalSignals.push('persistence');
157
+ if (/integration|integración|webhook|third-party|tercero/.test(goal)) goalSignals.push('external-integrations');
158
+ const complexitySignals = [...new Set([...(evidence.complexitySignals || []), ...goalSignals])].sort();
159
+ const needsSdd = isSoftware && (normalized.softwareScope !== 'small' || complexitySignals.length > 0);
160
+ const profile = needsSdd ? 'core' : 'minimal';
161
+ const catalog = loadManifest();
162
+ const catalogIds = new Set(catalog.skills.map((skill) => skill.id));
163
+ const detectedSkills = (evidence.stacks || []).filter((stack) => catalogIds.has(stack));
164
+ const skills = [...new Set([...skillsForProfile(catalog, profile), ...detectedSkills])].sort();
165
+ const baseAgents = needsSdd;
166
+ const hooks = needsSdd;
167
+ const agents = baseAgents ? resolveAgentNames(skills, []).sort() : [];
168
+ const gitmojiGuard = hooks && normalized.targets.includes('claude');
169
+ const decisions = skills.map((id) => detectedSkills.includes(id)
170
+ ? selected(id, 'skill', `Detected ${id} evidence inside the selected project root.`, 'workspace-evidence')
171
+ : selected(id, 'skill', needsSdd
172
+ ? (id === 'sdd' && complexitySignals.length
173
+ ? `Included because the accepted work has ${complexitySignals.join(', ')} complexity.`
174
+ : `Included in the development workflow for ${normalized.softwareScope} software.`)
175
+ : `Included in the lightweight foundation for this ${normalized.projectKind} project.`));
176
+ const sddTriggers = ['multiple related features', 'authentication or persistence', 'external integrations', 'cross-cutting changes'];
177
+ const softwareTriggers = [
178
+ { type: 'source-growth', atLeast: 5 },
179
+ { type: 'complexity-added', atLeast: 1 },
180
+ { type: 'manifest-with-implementation' },
181
+ ];
182
+ if (!needsSdd) decisions.push(deferred('sdd', 'workflow', isSoftware
183
+ ? 'The software scope is small, so specification overhead is not justified yet.'
184
+ : 'SDD applies to substantial software work, which is not the declared project purpose.', sddTriggers, softwareTriggers));
185
+ if (baseAgents) {
186
+ for (const id of agents) decisions.push(selected(id, 'agent', 'The accepted substantial software workflow requires this implementation or review role.'));
187
+ } else {
188
+ decisions.push(deferred('base-agents', 'agent', 'No substantial software implementation is planned.', ['substantial software implementation is introduced'], softwareTriggers));
189
+ }
190
+ decisions.push(hooks
191
+ ? selected('code-hooks', 'hook', 'The accepted software workflow needs its deterministic code gates.')
192
+ : deferred('code-hooks', 'hook', 'Code-only gates would add unrelated behavior to this project.', ['a substantial software workflow is accepted'], softwareTriggers));
193
+ decisions.push(gitmojiGuard
194
+ ? selected('gitmoji-guard', 'guard', 'Claude Code supports the commit guard and the accepted code policy includes it.')
195
+ : deferred('gitmoji-guard', 'guard', 'No selected target and project policy justify this Claude-only commit guard.', ['Claude Code is selected and a governed software workflow adopts the convention'], [{ type: 'software-trigger-and-claude' }]));
196
+ decisions.push(selected('memory', 'capability', 'Local bounded project memory supports continuity without an external account.'));
197
+ decisions.push(selected('harness-documents', 'route', 'The accepted profile and plan are persisted under 02-DOCS/wiki/harness/.'));
198
+ decisions.push(excluded('context7', 'integration', 'External MCP connections require a separate, provider-specific consent flow and are outside this local harness plan.'));
199
+ decisions.sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`));
200
+ const policy = {
201
+ skills,
202
+ targets: normalized.targets,
203
+ baseAgents,
204
+ agents,
205
+ alwaysOn: true,
206
+ codeHooks: needsSdd,
207
+ gitmojiGuard,
208
+ memory: true,
209
+ context7: false,
210
+ };
211
+ const root = evidence.root;
212
+ const governedPaths = root ? [...new Set([
213
+ '.rsc.json', '.rsc/backups/',
214
+ ...(existsSync(join(root, '.git')) ? ['.gitignore'] : []),
215
+ '02-DOCS/wiki/harness/user-profile.md',
216
+ '02-DOCS/wiki/harness/decisions.md',
217
+ '02-DOCS/wiki/harness/installation-plan.md',
218
+ '.rsc/.no-context7',
219
+ ...normalized.targets.flatMap((target) => managedPathsForInstall({ skillIds: skills, target, cwd: root, policy })
220
+ .map((path) => relative(root, path).split(sep).join('/'))),
221
+ ])].sort() : ['.rsc.json', '.rsc/', '02-DOCS/wiki/harness/'];
222
+ return {
223
+ schemaVersion: 1,
224
+ record: normalized,
225
+ evidence: {
226
+ schemaVersion: evidence.schemaVersion,
227
+ signals: [...evidence.signals], stacks: [...evidence.stacks],
228
+ complexitySignals: [...(evidence.complexitySignals || [])],
229
+ sourceFileCount: evidence.sourceFileCount, parentHarness: evidence.parentHarness,
230
+ },
231
+ decisions,
232
+ policy,
233
+ governedPaths,
234
+ };
235
+ }
236
+
237
+ function canonical(value) {
238
+ if (Array.isArray(value)) return value.map(canonical);
239
+ if (value && typeof value === 'object') {
240
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
241
+ }
242
+ return value;
243
+ }
244
+
245
+ export const canonicalJson = (value) => JSON.stringify(canonical(value));
246
+ export const identifyPlan = (plan) => createHash('sha256').update(canonicalJson(plan)).digest('hex');
247
+ export const shellQuote = (value) => `'${String(value).replaceAll("'", `'"'"'`)}'`;
248
+ export const encodeGoal = (value) => Buffer.from(String(value), 'utf8').toString('base64url');
249
+ export const decodeGoal = (value) => {
250
+ try {
251
+ const decoded = Buffer.from(String(value), 'base64url').toString('utf8');
252
+ return encodeGoal(decoded) === String(value).replace(/=+$/, '') ? decoded : undefined;
253
+ } catch { return undefined; }
254
+ };
255
+
256
+ export function recommendDeferredComponents(acceptedPlan, currentEvidence) {
257
+ const baseline = acceptedPlan?.evidence || {};
258
+ const grewBy = (currentEvidence?.sourceFileCount || 0) - (baseline.sourceFileCount || 0);
259
+ const addedManifests = (currentEvidence?.signals || []).filter((signal) => signal.startsWith('manifest:') && !(baseline.signals || []).includes(signal));
260
+ const addedComplexity = (currentEvidence?.complexitySignals || []).filter((signal) => !(baseline.complexitySignals || []).includes(signal));
261
+ const manifestWithImplementation = addedManifests.length && grewBy > 0;
262
+ const facts = { grewBy, manifestWithImplementation: Boolean(manifestWithImplementation), addedComplexity: addedComplexity.length };
263
+ const evidence = addedComplexity.length
264
+ ? `The project added ${addedComplexity.join(', ')} evidence.`
265
+ : grewBy >= 5
266
+ ? `The project grew by ${grewBy} source files since the accepted plan.`
267
+ : `The project added software manifest evidence: ${addedManifests.join(', ')}.`;
268
+ const suggestedRecord = {
269
+ ...acceptedPlan.record,
270
+ projectKind: ['software', 'mixed'].includes(acceptedPlan.record.projectKind) ? acceptedPlan.record.projectKind : 'mixed',
271
+ softwareScope: 'growing',
272
+ };
273
+ const matches = (rule) => {
274
+ if (rule.type === 'source-growth') return facts.grewBy >= rule.atLeast;
275
+ if (rule.type === 'complexity-added') return facts.addedComplexity >= rule.atLeast;
276
+ if (rule.type === 'manifest-with-implementation') return facts.manifestWithImplementation;
277
+ if (rule.type === 'software-trigger-and-claude') return acceptedPlan.record.targets.includes('claude') && (facts.grewBy >= 5 || facts.addedComplexity > 0 || facts.manifestWithImplementation);
278
+ return false;
279
+ };
280
+ return (acceptedPlan.decisions || [])
281
+ .filter((decision) => decision.state === 'deferred' && (decision.triggerRules || []).some(matches))
282
+ .map((decision) => ({
283
+ kind: decision.kind,
284
+ id: decision.id,
285
+ explanation: `${evidence} This matches: ${decision.reevaluateWhen.join('; ')}.`,
286
+ requiresNewPlan: true,
287
+ suggestedRecord,
288
+ }));
289
+ }
290
+
291
+ export function missingOnboardingFields(raw = {}) {
292
+ const missing = [];
293
+ if (!clean(raw.technicalLevel)) missing.push('technical-level');
294
+ if (!clean(raw.accompaniment)) missing.push('accompaniment');
295
+ if (!clean(raw.projectKind)) missing.push('project-kind');
296
+ if (!clean(raw.goal)) missing.push('goal');
297
+ const kind = clean(raw.projectKind).toLowerCase();
298
+ if ((kind === 'software' || kind === 'mixed') && !clean(raw.softwareScope)) missing.push('software-scope');
299
+ if (!(Array.isArray(raw.targets) ? raw.targets.length : clean(raw.targets))) missing.push('target');
300
+ return missing;
301
+ }