@ericrisco/rsc 1.2.2 → 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.
- package/README.md +46 -53
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/install-apply.js +158 -32
- package/scripts/install-plan.js +2 -2
- package/scripts/lib/manifest-file.js +2 -1
- package/scripts/lib/onboarding-apply.js +206 -0
- package/scripts/lib/onboarding.js +301 -0
- package/scripts/rsc.js +224 -9
- package/scripts/spec-gate.js +143 -11
- package/skills/design/scripts/verify.sh +4 -1
- package/skills/suggest/SKILL.md +3 -0
- package/targets/_md-block.js +5 -2
- package/targets/claude.js +26 -4
- package/targets/cursor.js +5 -2
- package/targets/index.js +2 -2
- package/targets/session-memory-adapter.mjs +16 -2
package/scripts/rsc.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { loadManifest, skillsForProfile } from './lib/manifest.js';
|
|
3
|
-
import { detectTarget, resolveTargets, TARGETS } from '../targets/index.js';
|
|
3
|
+
import { detectTarget, installedTargets, resolveTargets, TARGETS } from '../targets/index.js';
|
|
4
4
|
import { detectRepo } from './detect-repo.js';
|
|
5
5
|
import { rank } from './consult.js';
|
|
6
6
|
import { expandRecommends, toOutcomes, hasOutcome } from './lib/recommend.js';
|
|
7
7
|
import { applyInstall, listInstalled, listInstalledAgents, listInstalledCommands, uninstall, syncInstalled, purge, collisions } from './install-apply.js';
|
|
8
8
|
import { stackAgentNames } from '../targets/agents.js';
|
|
9
9
|
import { doctor } from './doctor.js';
|
|
10
|
-
import { say, select, pickFrom, banner, confirm, isInteractive } from './lib/ui.js';
|
|
10
|
+
import { ask, say, select, pickFrom, banner, confirm, isInteractive } from './lib/ui.js';
|
|
11
11
|
import { refreshRegistry, registryStatus } from './lib/registry.js';
|
|
12
12
|
import { audit, writeAuditReport } from './audit.js';
|
|
13
13
|
import { DOMAINS } from './lib/domains.js';
|
|
@@ -15,9 +15,32 @@ import { listBackups, restoreBackup } from './lib/backups.js';
|
|
|
15
15
|
import { runUpgrade } from './lib/upgrade.js';
|
|
16
16
|
import { diagnose, repair } from './lib/repair.js';
|
|
17
17
|
import { DEFAULT_SKILL_FLOOR, withDefaultSkillFloor } from './lib/default-skill-floor.js';
|
|
18
|
+
import { readManifest, writeManifest } from './lib/manifest-file.js';
|
|
19
|
+
import {
|
|
20
|
+
normalizeOnboarding, missingOnboardingFields, scanProject,
|
|
21
|
+
buildOnboardingPlan, decodeGoal, encodeGoal, identifyPlan, recommendDeferredComponents,
|
|
22
|
+
} from './lib/onboarding.js';
|
|
23
|
+
import { applyAcceptedOnboarding, verifyOnboarding } from './lib/onboarding-apply.js';
|
|
18
24
|
|
|
19
|
-
const
|
|
20
|
-
const
|
|
25
|
+
const rawArgv = process.argv.slice(2);
|
|
26
|
+
const GLOBAL_VALUE_FLAGS = new Set([
|
|
27
|
+
'--target', '--technical-level', '--accompaniment', '--project-kind', '--goal', '--goal-base64', '--software-scope', '--accept-plan',
|
|
28
|
+
]);
|
|
29
|
+
const COMMANDS = new Set(['onboard', 'reassess', 'add', 'install', 'consult', 'catalog', 'audit', 'list', 'doctor', 'memory', 'sync', 'backups', 'restore', 'upgrade', 'registry', 'worktrees', 'capabilities', 'sello', 'repair', 'uninstall', 'purge']);
|
|
30
|
+
function positionalTokens(input) {
|
|
31
|
+
const out = [];
|
|
32
|
+
for (let i = 0; i < input.length; i++) {
|
|
33
|
+
if (GLOBAL_VALUE_FLAGS.has(input[i]) || input[i] === '--profile' || input[i] === '--without') { i++; continue; }
|
|
34
|
+
if (input[i].startsWith('--')) continue;
|
|
35
|
+
out.push(input[i]);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
const candidate = positionalTokens(rawArgv)[0];
|
|
40
|
+
const cmd = candidate || undefined;
|
|
41
|
+
const argv = cmd && COMMANDS.has(cmd)
|
|
42
|
+
? [cmd, ...rawArgv.filter((_, index) => index !== rawArgv.indexOf(cmd))]
|
|
43
|
+
: rawArgv;
|
|
21
44
|
|
|
22
45
|
const targetLabel = (id) => TARGETS.find((t) => t.id === id)?.label || id;
|
|
23
46
|
|
|
@@ -32,6 +55,184 @@ function requestedIds(start = 1) {
|
|
|
32
55
|
return ids;
|
|
33
56
|
}
|
|
34
57
|
|
|
58
|
+
function onboardingInput(targets) {
|
|
59
|
+
const value = (name) => {
|
|
60
|
+
const v = flag(name);
|
|
61
|
+
return typeof v === 'string' && !v.startsWith('--') ? v : undefined;
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
schemaVersion: 1,
|
|
65
|
+
technicalLevel: value('technical-level'),
|
|
66
|
+
accompaniment: value('accompaniment'),
|
|
67
|
+
projectKind: value('project-kind'),
|
|
68
|
+
goal: value('goal') || (value('goal-base64') ? decodeGoal(value('goal-base64')) : undefined),
|
|
69
|
+
softwareScope: value('software-scope'),
|
|
70
|
+
targets,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function onboardingRequired(raw, action = 'onboard') {
|
|
75
|
+
const payload = {
|
|
76
|
+
code: 'RSC_ONBOARDING_REQUIRED',
|
|
77
|
+
schemaVersion: 1,
|
|
78
|
+
missing: missingOnboardingFields(raw),
|
|
79
|
+
recovery: `Run npx @ericrisco/rsc@latest ${action} --technical-level <non-technical|mixed|technical> --accompaniment <L0|L1|L2|L3> --project-kind <software|operations|research|content|mixed> --goal "<what you want>" --target <assistant>`,
|
|
80
|
+
};
|
|
81
|
+
console.error(`RSC_ONBOARDING_REQUIRED ${JSON.stringify(payload)}`);
|
|
82
|
+
process.exitCode = 2;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function hasDeclaredHarness(manifest = readManifest()) {
|
|
86
|
+
if (!manifest || manifest.version !== 1 || !Array.isArray(manifest.targets) || !manifest.targets.length
|
|
87
|
+
|| !manifest.targets.every((target) => TARGETS.some((known) => known.id === target))
|
|
88
|
+
|| !Array.isArray(manifest.skills) || !manifest.skills.length) return false;
|
|
89
|
+
const receipt = manifest.onboarding;
|
|
90
|
+
if (receipt) {
|
|
91
|
+
try {
|
|
92
|
+
const verified = receipt.schemaVersion === 1
|
|
93
|
+
&& identifyPlan(receipt.plan) === receipt.acceptedPlanId
|
|
94
|
+
&& verifyOnboarding(process.cwd(), receipt.plan, receipt.acceptedPlanId).length === 0;
|
|
95
|
+
if (verified) return true;
|
|
96
|
+
if (receipt.maintenanceDrift && installedTargets(process.cwd()).some((target) => manifest.targets.includes(target))) return true;
|
|
97
|
+
} catch { return false; }
|
|
98
|
+
}
|
|
99
|
+
// Backward compatibility is evidence-based: an actually installed pre-onboarding
|
|
100
|
+
// harness keeps its maintenance commands. A hand-written or merely committed
|
|
101
|
+
// declaration with no local state cannot bypass first contact.
|
|
102
|
+
return installedTargets(process.cwd()).some((target) => manifest.targets.includes(target));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function markMaintenanceDrift(action) {
|
|
106
|
+
const manifest = readManifest();
|
|
107
|
+
if (!manifest?.onboarding) return;
|
|
108
|
+
writeManifest(process.cwd(), {
|
|
109
|
+
...manifest,
|
|
110
|
+
onboarding: {
|
|
111
|
+
...manifest.onboarding,
|
|
112
|
+
maintenanceDrift: { action, recordedAt: new Date().toISOString(), requiresReassessment: true },
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderPlan(plan, planId) {
|
|
118
|
+
say('RSC_ONBOARDING_PLAN');
|
|
119
|
+
say(`Plan id: ${planId}`);
|
|
120
|
+
say(`Project: ${plan.record.projectKind}${plan.record.softwareScope ? ` (${plan.record.softwareScope})` : ''}`);
|
|
121
|
+
say(`Targets: ${plan.policy.targets.join(', ')}`);
|
|
122
|
+
say('Selected:');
|
|
123
|
+
for (const decision of plan.decisions.filter((d) => d.state === 'selected')) say(` + ${decision.kind}/${decision.id} — ${decision.reason}`);
|
|
124
|
+
say('Deferred:');
|
|
125
|
+
for (const decision of plan.decisions.filter((d) => d.state === 'deferred')) say(` - ${decision.kind}/${decision.id} — ${decision.reason} Reevaluate: ${decision.reevaluateWhen.join('; ')}`);
|
|
126
|
+
say('Excluded:');
|
|
127
|
+
for (const decision of plan.decisions.filter((d) => d.state === 'excluded')) say(` × ${decision.kind}/${decision.id} — ${decision.reason}`);
|
|
128
|
+
say('Managed paths:');
|
|
129
|
+
for (const path of plan.governedPaths) say(` ${path}`);
|
|
130
|
+
if (plan.evidence.parentHarness) say(`Parent harness detected at ${plan.evidence.parentHarness}; it is not inherited by this plan.`);
|
|
131
|
+
const pieces = [
|
|
132
|
+
'npx @ericrisco/rsc@latest onboard',
|
|
133
|
+
`--technical-level ${plan.record.technicalLevel}`,
|
|
134
|
+
`--accompaniment ${plan.record.accompaniment}`,
|
|
135
|
+
`--project-kind ${plan.record.projectKind}`,
|
|
136
|
+
`--goal-base64 ${encodeGoal(plan.record.goal)}`,
|
|
137
|
+
...(plan.record.softwareScope ? [`--software-scope ${plan.record.softwareScope}`] : []),
|
|
138
|
+
`--target ${plan.record.targets.join(',')}`,
|
|
139
|
+
`--accept-plan ${planId}`,
|
|
140
|
+
];
|
|
141
|
+
say(`Accept exactly this plan: ${pieces.join(' ')}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function runOnboarding(targets) {
|
|
145
|
+
const raw = onboardingInput(targets);
|
|
146
|
+
if (isInteractive() && missingOnboardingFields(raw).length) {
|
|
147
|
+
await banner(loadManifest().counts.skills);
|
|
148
|
+
say('Before rsc writes anything, it will learn how to help and show the exact harness plan.');
|
|
149
|
+
raw.technicalLevel ||= await select('How technical should the conversation be?', [
|
|
150
|
+
{ key: 'non-technical', label: 'Plain language — explain terms and avoid code jargon' },
|
|
151
|
+
{ key: 'mixed', label: 'Mixed — concise explanations with useful technical detail' },
|
|
152
|
+
{ key: 'technical', label: 'Technical — assume I am comfortable with code and tooling' },
|
|
153
|
+
]);
|
|
154
|
+
raw.accompaniment ||= await select('How much accompaniment do you want?', [
|
|
155
|
+
{ key: 'L0', label: 'L0 — results only' },
|
|
156
|
+
{ key: 'L1', label: 'L1 — brief reasons, questions only when needed' },
|
|
157
|
+
{ key: 'L2', label: 'L2 — explain each relevant decision' },
|
|
158
|
+
{ key: 'L3', label: 'L3 — guide me through everything' },
|
|
159
|
+
]);
|
|
160
|
+
raw.projectKind ||= await select('What are you building or running?', [
|
|
161
|
+
{ key: 'software', label: 'Software or a website' },
|
|
162
|
+
{ key: 'operations', label: 'Operations, a company or a process' },
|
|
163
|
+
{ key: 'research', label: 'Research or a knowledge base' },
|
|
164
|
+
{ key: 'content', label: 'Content or publishing' },
|
|
165
|
+
{ key: 'mixed', label: 'A combination of these' },
|
|
166
|
+
]);
|
|
167
|
+
raw.goal ||= await ask('What do you want this project to achieve? > ');
|
|
168
|
+
if ((raw.projectKind === 'software' || raw.projectKind === 'mixed') && !raw.softwareScope) {
|
|
169
|
+
raw.softwareScope = await select('How much software work is expected now?', [
|
|
170
|
+
{ key: 'small', label: 'Small — one focused tool or a few simple changes' },
|
|
171
|
+
{ key: 'growing', label: 'Growing — several related features or integrations' },
|
|
172
|
+
{ key: 'complex', label: 'Complex — multiple systems, teams or critical behavior' },
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
if (!raw.targets?.length) raw.targets = await selectAgents();
|
|
176
|
+
if (!raw.technicalLevel || !raw.accompaniment || !raw.projectKind || !raw.goal || !raw.targets?.length) {
|
|
177
|
+
say('Cancelled — nothing was touched.');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (missingOnboardingFields(raw).length) return onboardingRequired(raw);
|
|
182
|
+
let record;
|
|
183
|
+
try { record = normalizeOnboarding(raw); } catch (error) {
|
|
184
|
+
console.error(`RSC_ONBOARDING_INVALID: ${error.message}`);
|
|
185
|
+
process.exitCode = 2;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const plan = buildOnboardingPlan(record, scanProject(process.cwd()));
|
|
189
|
+
const planId = identifyPlan(plan);
|
|
190
|
+
const accepted = flag('accept-plan');
|
|
191
|
+
if (!accepted) {
|
|
192
|
+
renderPlan(plan, planId);
|
|
193
|
+
if (!isInteractive()) return;
|
|
194
|
+
if (!(await confirm('Accept this exact harness plan?'))) return void say('Cancelled — nothing was touched.');
|
|
195
|
+
}
|
|
196
|
+
if (accepted && accepted !== planId) {
|
|
197
|
+
console.error(`RSC_PLAN_CHANGED: accepted ${accepted}, current plan is ${planId}. Regenerate the plan and ask the user to accept the new id.`);
|
|
198
|
+
process.exitCode = 3;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
await applyAcceptedOnboarding({ cwd: process.cwd(), plan, planId });
|
|
203
|
+
say(`RSC_ONBOARDING_READY ${planId}`);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
console.error(error.message);
|
|
206
|
+
process.exitCode = 4;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function runReassessment() {
|
|
211
|
+
const manifest = readManifest();
|
|
212
|
+
const plan = manifest?.onboarding?.plan;
|
|
213
|
+
if (!plan) {
|
|
214
|
+
console.error('RSC_ONBOARDING_REQUIRED: no accepted onboarding receipt. Run npx @ericrisco/rsc@latest onboard');
|
|
215
|
+
process.exitCode = 2;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const recommendations = recommendDeferredComponents(plan, scanProject(process.cwd()));
|
|
219
|
+
if (!recommendations.length) return void say('RSC_REASSESSMENT_NO_CHANGE');
|
|
220
|
+
say('RSC_REASSESSMENT_RECOMMENDED');
|
|
221
|
+
for (const item of recommendations) say(` ${item.kind}/${item.id}: ${item.explanation}`);
|
|
222
|
+
const record = recommendations[0].suggestedRecord || plan.record;
|
|
223
|
+
say('Review a new plan; nothing has been installed:');
|
|
224
|
+
say([
|
|
225
|
+
'npx @ericrisco/rsc@latest onboard',
|
|
226
|
+
`--technical-level ${record.technicalLevel}`,
|
|
227
|
+
`--accompaniment ${record.accompaniment}`,
|
|
228
|
+
`--project-kind ${record.projectKind}`,
|
|
229
|
+
`--goal-base64 ${encodeGoal(record.goal)}`,
|
|
230
|
+
...(record.softwareScope ? [`--software-scope ${record.softwareScope}`] : []),
|
|
231
|
+
`--target ${record.targets.join(',')}`,
|
|
232
|
+
].join(' '));
|
|
233
|
+
say('Show the new plan and ask the user to accept its id before applying it.');
|
|
234
|
+
}
|
|
235
|
+
|
|
35
236
|
function editDistance(a, b) {
|
|
36
237
|
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
37
238
|
for (let i = 1; i <= a.length; i++) {
|
|
@@ -73,7 +274,7 @@ function reportUnknown(ids) {
|
|
|
73
274
|
// flag says which: `purge` sweeps every target by design, and the catalog/consult
|
|
74
275
|
// side barely touches one, so blocking them would be a regression, not a safeguard.
|
|
75
276
|
const NEEDS_TARGET = new Set([
|
|
76
|
-
'add', 'install', 'list', 'doctor', 'sync', 'uninstall', 'capabilities', 'catalog', 'registry',
|
|
277
|
+
'onboard', 'add', 'install', 'list', 'doctor', 'sync', 'uninstall', 'capabilities', 'catalog', 'registry',
|
|
77
278
|
]);
|
|
78
279
|
|
|
79
280
|
function flag(name) {
|
|
@@ -317,24 +518,38 @@ async function main() {
|
|
|
317
518
|
const target = targets[0];
|
|
318
519
|
switch (cmd) {
|
|
319
520
|
case undefined:
|
|
320
|
-
return wizard(f ? targets : null);
|
|
521
|
+
return hasDeclaredHarness() ? wizard(f ? targets : null) : runOnboarding(f ? targets : []);
|
|
522
|
+
case 'onboard':
|
|
523
|
+
return runOnboarding(f ? targets : []);
|
|
524
|
+
case 'reassess':
|
|
525
|
+
return runReassessment();
|
|
321
526
|
case 'add': {
|
|
527
|
+
if (!hasDeclaredHarness()) return onboardingRequired(onboardingInput(targets));
|
|
322
528
|
const requested = requestedIds();
|
|
323
529
|
const selected = classifyRequested(requested);
|
|
324
530
|
if (reportUnknown(selected.unknown)) return;
|
|
325
531
|
const ids = withDefaultSkillFloor(selected.skills);
|
|
326
532
|
if (!argv.includes('--force') && !(await guardCollisions(targets, ids))) return;
|
|
327
|
-
|
|
533
|
+
const currentManifest = readManifest();
|
|
534
|
+
const receipt = currentManifest?.onboarding;
|
|
535
|
+
const maintainedIds = receipt ? [...new Set([...(currentManifest.skills || []), ...(receipt.plan.policy.skills || []), ...ids])].sort() : ids;
|
|
536
|
+
const policy = receipt ? { ...receipt.plan.policy, skills: maintainedIds } : undefined;
|
|
537
|
+
for (const t of targets) await applyInstall({ skillIds: maintainedIds, agentIds: selected.agents, target: t, policy });
|
|
538
|
+
markMaintenanceDrift(`add ${requested.join(',')}`);
|
|
328
539
|
say(`✅ Installed for ${targets.join(', ')}: ${requested.join(', ')}`);
|
|
329
540
|
return void say(' ↻ Reload/restart your assistant so the new skill activates.');
|
|
330
541
|
}
|
|
331
542
|
case 'install': {
|
|
543
|
+
if (!hasDeclaredHarness()) return onboardingRequired(onboardingInput(targets));
|
|
332
544
|
const profile = flag('profile') || 'minimal';
|
|
333
545
|
const without = argv.filter((a, i) => argv[i - 1] === '--without');
|
|
334
546
|
let ids = skillsForProfile(loadManifest(), profile);
|
|
335
547
|
ids = withDefaultSkillFloor(ids).filter((id) => !without.includes(id));
|
|
336
548
|
if (!argv.includes('--force') && !(await guardCollisions(targets, ids))) return;
|
|
337
|
-
|
|
549
|
+
const receipt = readManifest()?.onboarding;
|
|
550
|
+
const policy = receipt ? { ...receipt.plan.policy, skills: ids } : undefined;
|
|
551
|
+
for (const t of targets) await applyInstall({ skillIds: ids, target: t, policy });
|
|
552
|
+
markMaintenanceDrift(`install ${profile}`);
|
|
338
553
|
say(`✅ Profile '${profile}' installed for ${targets.join(', ')} (${ids.length} skills)`);
|
|
339
554
|
printAgentHandoff();
|
|
340
555
|
return;
|
|
@@ -806,7 +1021,7 @@ async function main() {
|
|
|
806
1021
|
return void (await runPurge(argv.includes('--dry-run'), argv.includes('--with-docs')));
|
|
807
1022
|
default:
|
|
808
1023
|
say(`rsc: unknown command '${cmd}'.`);
|
|
809
|
-
say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | memory <on|off|status|save|resume|learn|metrics> | sello <on|off|status|…> | worktrees [reap [path] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id> | purge');
|
|
1024
|
+
say('Use: npx @ericrisco/rsc onboard | reassess | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | memory <on|off|status|save|resume|learn|metrics> | sello <on|off|status|…> | worktrees [reap [path] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id> | purge');
|
|
810
1025
|
say('Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)');
|
|
811
1026
|
say(' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.');
|
|
812
1027
|
process.exitCode = 1;
|
package/scripts/spec-gate.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// Not wired into `prepublishOnly`, and that is deliberate: specs live under 02-DOCS, which is never
|
|
5
5
|
// tracked (principle 9), so a publish gate depending on them would fail in any clean clone. This is
|
|
6
6
|
// the phase's own instrument, and the author's.
|
|
7
|
-
import { readFileSync, readdirSync } from 'node:fs';
|
|
8
|
-
import { join, dirname, basename } from 'node:path';
|
|
7
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
8
|
+
import { join, dirname, basename, resolve, relative } from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { execFileSync } from 'node:child_process';
|
|
11
11
|
import { specCompleteness, statusClaims, checkClaims } from './lib/spec-gate.js';
|
|
@@ -25,11 +25,19 @@ function defaultTargets() {
|
|
|
25
25
|
|
|
26
26
|
// The repository, asked instead of assumed. Returns null when there is nothing to ask — a
|
|
27
27
|
// report-only instrument that crashes where it cannot look is worse than one that says so.
|
|
28
|
+
function spawnSyncGit(cwd, args) {
|
|
29
|
+
try { return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { return ''; }
|
|
30
|
+
}
|
|
31
|
+
|
|
28
32
|
function gitProbe(root) {
|
|
29
33
|
const git = (args) => execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
30
34
|
let main;
|
|
35
|
+
let top;
|
|
31
36
|
try {
|
|
32
37
|
git(['rev-parse', '--git-dir']);
|
|
38
|
+
// The repository, not the directory we happened to be handed: `children()` looks for sibling
|
|
39
|
+
// repos under this one, and asking a specs/ subdirectory for its children finds nothing.
|
|
40
|
+
top = git(['rev-parse', '--show-toplevel']) || root;
|
|
33
41
|
// Prefer the remote tip — a stale local `main` reports merged work as missing. But CI checks out
|
|
34
42
|
// detached with no `origin/main` and no local `main`, and falling through to "no repository to
|
|
35
43
|
// ask" there means the check silently stops checking exactly where it runs unattended. HEAD is
|
|
@@ -44,12 +52,118 @@ function gitProbe(root) {
|
|
|
44
52
|
return {
|
|
45
53
|
hasCommit: (sha) => { try { git(['cat-file', '-e', `${sha}^{commit}`]); return true; } catch { return false; } },
|
|
46
54
|
isAncestor: (sha) => { try { git(['merge-base', '--is-ancestor', sha, main]); return true; } catch { return false; } },
|
|
47
|
-
//
|
|
48
|
-
|
|
55
|
+
// Two ways a pull request lands, and only one of them was recognised. The squash subject carries
|
|
56
|
+
// `(#N)`; a plain merge writes `Merge pull request #N from …` and matched nothing, so half the
|
|
57
|
+
// ways of landing were invisible even when the right repository was being asked.
|
|
58
|
+
// Two literal searches rather than one regex: `--fixed-strings` is what keeps the parens from
|
|
59
|
+
// being read as a group, and swapping it for an alternation would put that back.
|
|
60
|
+
hasPr: (n) => {
|
|
61
|
+
for (const needle of [`(#${n})`, `Merge pull request #${n} `]) {
|
|
62
|
+
try {
|
|
63
|
+
if (git(['log', main, '--grep', needle, '--fixed-strings', '-1', '--format=%H']) !== '') return true;
|
|
64
|
+
} catch { /* try the other form */ }
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
},
|
|
49
68
|
hasTag: (v) => { try { return git(['tag', '-l', `v${v}`]) !== ''; } catch { return false; } },
|
|
69
|
+
root: top,
|
|
50
70
|
};
|
|
51
71
|
}
|
|
52
72
|
|
|
73
|
+
const commonDir = (dir) => {
|
|
74
|
+
const r = spawnSyncGit(dir, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
75
|
+
return r || dir;
|
|
76
|
+
};
|
|
77
|
+
const sameRepo = (a, b) => commonDir(a) === commonDir(b);
|
|
78
|
+
|
|
79
|
+
const isRepo = (dir) => { try { return statSync(join(dir, '.git')).isDirectory() || statSync(join(dir, '.git')).isFile(); } catch { return false; } };
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Which repository is this spec's `status:` talking about?
|
|
83
|
+
*
|
|
84
|
+
* It used to be "the one holding the file", which is right only when docs and code live together.
|
|
85
|
+
* Where they deliberately do not — this workspace's own CLAUDE.md requires the children's working
|
|
86
|
+
* docs to live in the container — every true claim came back false, and a gate that flags everything
|
|
87
|
+
* distinguishes nothing.
|
|
88
|
+
*
|
|
89
|
+
* The subject is pinned ONCE per spec and every claim is then judged against it. Letting each claim
|
|
90
|
+
* find whichever repository happens to confirm it would read better and destroy the function: an
|
|
91
|
+
* unconfirmed claim would simply find no confirmer, and nothing would ever be reported stale again.
|
|
92
|
+
*/
|
|
93
|
+
function resolveSubject(specPath, claims) {
|
|
94
|
+
const answers = (root) => {
|
|
95
|
+
const p = gitProbe(root);
|
|
96
|
+
if (!p) return null;
|
|
97
|
+
return claims.some((c) => (c.kind === 'sha' && p.hasCommit(c.value))
|
|
98
|
+
|| (c.kind === 'version' && p.hasTag(c.value))
|
|
99
|
+
|| (c.kind === 'pr' && p.hasPr(c.value))) ? p : null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// The declaration is AUTHORITATIVE. Softening it to "wins when it can answer" fixed a wrong config
|
|
103
|
+
// and opened something worse: with sibling repos, a claim that is false where the spec belongs and
|
|
104
|
+
// true next door is handed to the neighbour, confirmed, and printed nowhere. A silent false negative
|
|
105
|
+
// is worse than the noisy false positive it replaced, and choosing the judge by who agrees with the
|
|
106
|
+
// claim is not a way to find out whether the claim is true. A wrong `project.root` is a wrong datum;
|
|
107
|
+
// fix the datum.
|
|
108
|
+
const declared = declaredRoot(specPath);
|
|
109
|
+
if (declared && isRepo(declared)) return { probe: gitProbe(declared), from: 'config' };
|
|
110
|
+
|
|
111
|
+
const here = gitProbe(dirname(specPath));
|
|
112
|
+
const home = here && here.root;
|
|
113
|
+
|
|
114
|
+
if (home) {
|
|
115
|
+
const kids = children(home).map((dir) => ({ dir, probe: answers(dir) })).filter((k) => k.probe);
|
|
116
|
+
// Two histories cannot both be the subject. Saying so is an answer; picking the first one that
|
|
117
|
+
// sorted alphabetically is a coin toss wearing a verdict's clothes.
|
|
118
|
+
if (kids.length > 1) {
|
|
119
|
+
return { probe: null, note: `several repositories here could answer it (${kids.map((k) => basename(k.dir)).join(', ')}) — say which one in project.root` };
|
|
120
|
+
}
|
|
121
|
+
if (kids.length === 1) return { probe: kids[0].probe, from: 'derived' };
|
|
122
|
+
// Nobody answered. Fall through to the floor rather than guessing: with no declaration and no
|
|
123
|
+
// recogniser, the spec's own repository is the only honest default.
|
|
124
|
+
}
|
|
125
|
+
// The floor is the old behaviour: a spec that lives beside its own code must keep the detection it
|
|
126
|
+
// already had, or fixing the split layout would downgrade every caught lie to "could not check".
|
|
127
|
+
const floor = declared && isRepo(declared) ? gitProbe(declared) : here;
|
|
128
|
+
return { probe: floor, from: declared ? 'config' : 'spec repo' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `project.root` from the nearest SDD config, resolved against the directory that holds 02-DOCS. */
|
|
132
|
+
function declaredRoot(specPath) {
|
|
133
|
+
let dir = resolve(dirname(specPath));
|
|
134
|
+
for (let i = 0; i < 24; i += 1) {
|
|
135
|
+
const cfg = join(dir, '02-DOCS', 'wiki', 'sdd', 'config.yaml');
|
|
136
|
+
if (existsSync(cfg)) {
|
|
137
|
+
try {
|
|
138
|
+
const block = /^project:[ \t]*\r?\n((?:[ \t]+.*\r?\n?)*)/m.exec(readFileSync(cfg, 'utf8'));
|
|
139
|
+
const m = block && /^[ \t]+root:[ \t]*(.+?)[ \t]*$/m.exec(block[1]);
|
|
140
|
+
// Relative to the directory that owns 02-DOCS, not to the config file and not to the cwd:
|
|
141
|
+
// the only reading where `.` keeps meaning what it means today and the answer does not change
|
|
142
|
+
// with where the gate was invoked from.
|
|
143
|
+
if (m) return resolve(dir, m[1].replace(/^['"]|['"]$/g, ''));
|
|
144
|
+
} catch { /* unreadable config → derive instead */ }
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const up = dirname(dir);
|
|
148
|
+
if (up === dir) break;
|
|
149
|
+
dir = up;
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Git repositories directly inside `root` — far enough to find a child, near enough to still be this project. */
|
|
155
|
+
function children(root) {
|
|
156
|
+
try {
|
|
157
|
+
return readdirSync(root, { withFileTypes: true })
|
|
158
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
|
|
159
|
+
.map((e) => join(root, e.name))
|
|
160
|
+
.filter(isRepo)
|
|
161
|
+
// A linked worktree has a `.git` file and the same history, so without this a repo and its own
|
|
162
|
+
// worktree look like two candidates that both answer — an ambiguity invented out of one repo.
|
|
163
|
+
.filter((dir, i, all) => all.findIndex((d) => sameRepo(d, dir)) === i);
|
|
164
|
+
} catch { return []; }
|
|
165
|
+
}
|
|
166
|
+
|
|
53
167
|
const statusOf = (text) => (/^status:[ \t]*(.*)$/m.exec(text) || [, ''])[1];
|
|
54
168
|
|
|
55
169
|
function main() {
|
|
@@ -66,11 +180,17 @@ function main() {
|
|
|
66
180
|
// the project the spec belongs to — checking them against wherever this script happens to live
|
|
67
181
|
// gives the right answer only by coincidence, and the wrong one the moment the gate is pointed at
|
|
68
182
|
// another checkout.
|
|
69
|
-
const
|
|
70
|
-
const
|
|
183
|
+
const subjects = new Map();
|
|
184
|
+
const announced = new Set();
|
|
185
|
+
const subjectFor = (path, claims) => {
|
|
71
186
|
const dir = dirname(path);
|
|
72
|
-
|
|
73
|
-
|
|
187
|
+
// Keyed by directory alone. It used to include the claims, which meant a fresh resolution per
|
|
188
|
+
// spec — the opposite of what its own comment claimed, and measured at 0.8s → 10.3s over a
|
|
189
|
+
// 36-spec corpus, because every miss re-walks the children and re-runs git. A corpus shares a
|
|
190
|
+
// subject; that is the whole premise.
|
|
191
|
+
const key = dir;
|
|
192
|
+
if (!subjects.has(key)) subjects.set(key, resolveSubject(path, claims));
|
|
193
|
+
return subjects.get(key);
|
|
74
194
|
};
|
|
75
195
|
let anyProbe = false;
|
|
76
196
|
for (const path of targets) {
|
|
@@ -92,12 +212,24 @@ function main() {
|
|
|
92
212
|
}
|
|
93
213
|
// A status that still matches the repository prints nothing: the report grows only where there
|
|
94
214
|
// is something to act on (P5).
|
|
95
|
-
const
|
|
215
|
+
const claims = statusClaims(statusOf(text));
|
|
216
|
+
const subject = claims.length ? subjectFor(path, claims) : { probe: null };
|
|
217
|
+
// A subject nobody declared was inferred from the claims themselves, which is a guess. Say it
|
|
218
|
+
// once per run: silent inference is how a coincidence passes for a verdict.
|
|
219
|
+
if (subject.from === 'derived' && subject.probe && !announced.has(subject.probe.root)) {
|
|
220
|
+
announced.add(subject.probe.root);
|
|
221
|
+
console.log(` note: subject derived (not declared) — asking ${subject.probe.root}; set project.root to settle it`);
|
|
222
|
+
}
|
|
223
|
+
const probe = subject.probe;
|
|
96
224
|
anyProbe = anyProbe || Boolean(probe);
|
|
97
|
-
for (const v of checkClaims(
|
|
225
|
+
for (const v of checkClaims(claims, probe)) {
|
|
98
226
|
if (v.verdict === 'holds') continue;
|
|
99
227
|
if (v.verdict === 'stale') drifted += 1;
|
|
100
|
-
|
|
228
|
+
// Every non-green line names where it looked, so the reader can go and check by hand (P6).
|
|
229
|
+
const rel = probe ? relative(process.cwd(), probe.root) : '';
|
|
230
|
+
const where = probe ? ` [asked ${!rel || rel.startsWith('..') ? probe.root : rel}]` : '';
|
|
231
|
+
const reason = !probe && subject.note ? subject.note : v.reason;
|
|
232
|
+
console.log(` status ${v.verdict.toUpperCase()}: ${v.raw} — ${reason}${where}`);
|
|
101
233
|
}
|
|
102
234
|
}
|
|
103
235
|
|
|
@@ -91,7 +91,10 @@ search() {
|
|
|
91
91
|
# only exercising whichever one happens to be installed.
|
|
92
92
|
if have rg && [ -z "${VERIFY_FORCE_GREP:-}" ]; then
|
|
93
93
|
# rg already ignores .gitignore'd paths (node_modules, .next, dist); also skip self.
|
|
94
|
-
|
|
94
|
+
# Always name the search root. run_tell_table feeds its registry through stdin;
|
|
95
|
+
# without an explicit path ripgrep treats that stdin as the search corpus and
|
|
96
|
+
# silently checks the registry text instead of the project files.
|
|
97
|
+
rg -n --no-heading --glob "!$SELF_NAME" "$@" .
|
|
95
98
|
else
|
|
96
99
|
# last arg is the pattern; everything before are paths/globs we ignore for grep.
|
|
97
100
|
# `${*: -1}` (last positional) is supported on bash 3.2; the space before -1 is required.
|
package/skills/suggest/SKILL.md
CHANGED
|
@@ -42,6 +42,9 @@ request. If the user engaged **SDD autopilot**, that one consent covers the whol
|
|
|
42
42
|
through the phases without re-asking.
|
|
43
43
|
|
|
44
44
|
If `specify` / `sdd` are not installed, offer to add them (§2) before routing.
|
|
45
|
+
When `.rsc.json` records SDD as deferred, first run `npx @ericrisco/rsc@latest reassess`.
|
|
46
|
+
Stay silent on `RSC_REASSESSMENT_NO_CHANGE`. If it reports new evidence, explain what changed and
|
|
47
|
+
show the new plan command; SDD still needs a newly accepted plan id and is never added silently.
|
|
45
48
|
|
|
46
49
|
Method, phase map and full decision table: `../sdd/SKILL.md`. On Claude Code this rule also arrives
|
|
47
50
|
as a per-turn hook; the brevity here is deduplication, not relaxation.
|
package/targets/_md-block.js
CHANGED
|
@@ -17,8 +17,11 @@ export function writeSkill(id, fromDir, toPath) {
|
|
|
17
17
|
return linkOrCopy(fromDir, toPath);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export function wireHook(paths, sourceMd) {
|
|
21
|
-
const
|
|
20
|
+
export function wireHook(paths, sourceMd, policy = {}) {
|
|
21
|
+
const full = stripFrontmatter(readFileSync(sourceMd, 'utf8'));
|
|
22
|
+
const body = policy.codeHooks === false
|
|
23
|
+
? `# rsc-suggest — always-on operations layer\n\nRead \`02-DOCS/wiki/harness/user-profile.md\` before acting. Use \`orient\` to keep the user situated and \`suggest\` to offer a missing skill only when the current task needs it. Close with the configured orientation block.\n`
|
|
24
|
+
: full;
|
|
22
25
|
const block = `${MARK_START}\n${body}\n${MARK_END}`;
|
|
23
26
|
let doc = existsSync(paths.hookTarget) ? readFileSync(paths.hookTarget, 'utf8') : '';
|
|
24
27
|
if (doc.includes(MARK_START)) {
|
package/targets/claude.js
CHANGED
|
@@ -79,7 +79,7 @@ export function unwireHook(paths) {
|
|
|
79
79
|
const P = '${CLAUDE_PROJECT_DIR}';
|
|
80
80
|
const at = (...seg) => `${P}/${seg.join('/')}`;
|
|
81
81
|
|
|
82
|
-
export function wireHook(paths) {
|
|
82
|
+
export function wireHook(paths, sourceMd, policy = {}) {
|
|
83
83
|
const scriptDest = join(paths.projectRoot, '.rsc', 'session-start.mjs');
|
|
84
84
|
mkdirSync(dirname(scriptDest), { recursive: true });
|
|
85
85
|
// Shared by session-start + userprompt-gate. Hooks are materialized file by file, so a module
|
|
@@ -90,8 +90,12 @@ export function wireHook(paths) {
|
|
|
90
90
|
copyFileSync(join(HERE, 'worktree-reaper.mjs'), join(paths.projectRoot, '.rsc', 'worktree-reaper.mjs'));
|
|
91
91
|
copyFileSync(join(HERE, 'session-start.mjs'), scriptDest);
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
const
|
|
93
|
+
let suggestRel = at(relative(paths.projectRoot, paths.skillDir('suggest')).split(sep).join('/'), 'SKILL.md');
|
|
94
|
+
const operationsSuggest = join(paths.projectRoot, '.rsc', 'suggest-always-on.md');
|
|
95
|
+
if (policy.codeHooks === false) {
|
|
96
|
+
writeFileSync(operationsSuggest, '# rsc-suggest — always-on operations layer\n\nRead `02-DOCS/wiki/harness/user-profile.md` before acting. Use `orient` to keep the user situated and `suggest` to offer a missing skill only when the current task needs it. Close with the configured orientation block.\n');
|
|
97
|
+
suggestRel = at('.rsc', 'suggest-always-on.md');
|
|
98
|
+
} else if (existsSync(operationsSuggest)) rmSync(operationsSuggest, { force: true });
|
|
95
99
|
const cmd = `node "${at('.rsc', 'session-start.mjs')}" "${suggestRel}" "${P}"`;
|
|
96
100
|
|
|
97
101
|
const file = paths.hookTarget;
|
|
@@ -111,6 +115,7 @@ export function wireHook(paths) {
|
|
|
111
115
|
// the workspace has no harness wiki. Registered idempotently on both events, with
|
|
112
116
|
// any prior rsc worklog-checkpoint entry (.sh or .mjs) dropped first.
|
|
113
117
|
const wlDest = join(paths.projectRoot, '.rsc', 'worklog-checkpoint.mjs');
|
|
118
|
+
const written = [paths.hookTarget, scriptDest, wlDest];
|
|
114
119
|
copyFileSync(join(HERE, 'worklog-checkpoint.mjs'), wlDest);
|
|
115
120
|
const wlCmd = `node "${at('.rsc', 'worklog-checkpoint.mjs')}" "${P}"`;
|
|
116
121
|
for (const event of ['PreCompact', 'SessionEnd']) {
|
|
@@ -121,6 +126,7 @@ export function wireHook(paths) {
|
|
|
121
126
|
settings.hooks[event].push({ hooks: [{ type: 'command', command: wlCmd }] });
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
if (policy.codeHooks !== false) {
|
|
124
130
|
// Ship guard: a PreToolUse(Bash) hook that DENIES switching to / merging the trunk
|
|
125
131
|
// while the current feature branch has uncommitted or unpushed work — forcing the
|
|
126
132
|
// commit → push → PR close (the `ship` skill). Materialized + node-run (Windows-safe),
|
|
@@ -182,8 +188,24 @@ export function wireHook(paths) {
|
|
|
182
188
|
(e) => !hookWiringOf(e).includes('.rsc/userprompt-gate.'),
|
|
183
189
|
);
|
|
184
190
|
settings.hooks.UserPromptSubmit.push({ hooks: [{ type: 'command', command: fgCmd }] });
|
|
191
|
+
written.push(sgDest, dgDest, gmDest, fgDest, join(paths.projectRoot, '.rsc', 'sello.mjs'));
|
|
192
|
+
} else {
|
|
193
|
+
for (const event of ['PreToolUse', 'UserPromptSubmit']) {
|
|
194
|
+
if (!settings.hooks[event]) continue;
|
|
195
|
+
settings.hooks[event] = settings.hooks[event].filter((entry) => {
|
|
196
|
+
const body = hookWiringOf(entry);
|
|
197
|
+
return !body.includes('.rsc/ship-guard.') && !body.includes('.rsc/danger-guard.') &&
|
|
198
|
+
!body.includes('.rsc/gitmoji-guard.') && !body.includes('.rsc/userprompt-gate.');
|
|
199
|
+
});
|
|
200
|
+
if (!settings.hooks[event].length) delete settings.hooks[event];
|
|
201
|
+
}
|
|
202
|
+
for (const name of ['ship-guard.mjs', 'danger-guard.mjs', 'gitmoji-guard.mjs', 'userprompt-gate.mjs', 'sello.mjs']) {
|
|
203
|
+
rmSync(join(paths.projectRoot, '.rsc', name), { force: true });
|
|
204
|
+
}
|
|
205
|
+
written.push(operationsSuggest);
|
|
206
|
+
}
|
|
185
207
|
|
|
186
208
|
mkdirSync(dirname(file), { recursive: true });
|
|
187
209
|
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
188
|
-
return
|
|
210
|
+
return written;
|
|
189
211
|
}
|
package/targets/cursor.js
CHANGED
|
@@ -8,8 +8,11 @@ export function writeSkill(id, fromDir, toPath) {
|
|
|
8
8
|
return [toPath];
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export function wireHook(paths, sourceMd) {
|
|
12
|
-
const
|
|
11
|
+
export function wireHook(paths, sourceMd, policy = {}) {
|
|
12
|
+
const full = stripFrontmatter(readFileSync(sourceMd, 'utf8'));
|
|
13
|
+
const body = policy.codeHooks === false
|
|
14
|
+
? `# rsc-suggest — always-on operations layer\n\nRead \`02-DOCS/wiki/harness/user-profile.md\` before acting. Use \`orient\` and offer missing skills through \`suggest\` only when needed.\n`
|
|
15
|
+
: full;
|
|
13
16
|
mkdirSync(dirname(paths.hookTarget), { recursive: true });
|
|
14
17
|
writeFileSync(paths.hookTarget, `---\ndescription: rsc auto-suggest\nalwaysApply: true\n---\n${body}`);
|
|
15
18
|
return [paths.hookTarget];
|
package/targets/index.js
CHANGED
|
@@ -169,8 +169,8 @@ export function writeSkill(target, id, fromDir, toPath) {
|
|
|
169
169
|
return ADAPTER[SPEC[target].adapter].writeSkill(id, fromDir, toPath);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
export function wireHook(target, paths, sourceMd) {
|
|
173
|
-
return ADAPTER[SPEC[target].adapter].wireHook(paths, sourceMd);
|
|
172
|
+
export function wireHook(target, paths, sourceMd, policy) {
|
|
173
|
+
return ADAPTER[SPEC[target].adapter].wireHook(paths, sourceMd, policy);
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
// Inverse of wireHook — remove rsc's always-on surface for a target (settings.json
|