@ericrisco/rsc 1.1.3 → 1.1.5
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 +70 -0
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/doctor.js +68 -1
- package/scripts/install-apply.js +91 -16
- package/scripts/lib/divergence.js +33 -0
- package/scripts/lib/manifest-file.js +53 -0
- package/scripts/lib/repair.js +156 -0
- package/scripts/lib/spec-gate.js +98 -0
- package/scripts/lib/ui.js +5 -0
- package/scripts/rsc.js +84 -11
- package/scripts/spec-gate.js +57 -4
- package/skills/suggest/SKILL.md +25 -1
- package/targets/_md-block.js +17 -4
- package/targets/claude.js +17 -7
- package/targets/index.js +66 -19
package/README.md
CHANGED
|
@@ -52,6 +52,7 @@ user wants**, reads the repo, then installs the floor (`orient` + `rsc-suggest`
|
|
|
52
52
|
`init`) plus only the skills that fit — one at a time. No global install and no API key needed.
|
|
53
53
|
|
|
54
54
|
- **Choose assistants non-interactively:** `npx @ericrisco/rsc@latest --target claude` (comma-separate for several).
|
|
55
|
+
- **Two assistants already installed?** rsc asks instead of guessing. `--target` settles it in one word.
|
|
55
56
|
- **Already installed, just refreshing skills + hooks:** `rsc sync` (or re-run the command above).
|
|
56
57
|
- **Add one skill by id:** `rsc add <id>` · **browse the catalog:** `rsc consult "<what you want>"` or `rsc list`.
|
|
57
58
|
|
|
@@ -203,6 +204,75 @@ rsc uninstall postgresdb --dry-run # preview a removal
|
|
|
203
204
|
|
|
204
205
|
---
|
|
205
206
|
|
|
207
|
+
|
|
208
|
+
## 👥 Sharing a harness with your team
|
|
209
|
+
|
|
210
|
+
The harness travels by git, but not all of it — and the split is the point.
|
|
211
|
+
|
|
212
|
+
**Commit these:**
|
|
213
|
+
|
|
214
|
+
| | |
|
|
215
|
+
| --- | --- |
|
|
216
|
+
| `.rsc.json` | The decision: which assistants, which skills, **which catalog version**, the developer tier, which gates you disarmed |
|
|
217
|
+
| `01-TOOLS/` · `02-DOCS/` | Your tooling and your wiki, if you use them |
|
|
218
|
+
| Skills and agents you wrote by hand | They are yours. rsc does not claim them, does not count them as drift, and does not touch them |
|
|
219
|
+
|
|
220
|
+
**Do not commit these** — rsc adds them to `.gitignore` for you:
|
|
221
|
+
|
|
222
|
+
| | Why |
|
|
223
|
+
| --- | --- |
|
|
224
|
+
| `.rsc/` | Machine state: hook scripts, seals, logs |
|
|
225
|
+
| The skill entries rsc manages | Symlinks on macOS/Linux, real copies on Windows — two incompatible shapes of one thing |
|
|
226
|
+
|
|
227
|
+
Whoever clones runs **one command** and ends up with the same harness:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
npx @ericrisco/rsc@latest sync
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Same skills, same version — `.rsc.json` pins the catalog, so a teammate who clones in three
|
|
234
|
+
months gets what you had, not what shipped since. Upgrading is a deliberate act, never a side
|
|
235
|
+
effect of rebuilding.
|
|
236
|
+
|
|
237
|
+
When someone changes the harness and you `git pull`, `rsc doctor` tells you what no longer
|
|
238
|
+
matches. **Nothing is ever written to your machine by a pull** — you are told, and you decide.
|
|
239
|
+
|
|
240
|
+
**Own skills.** A skill your team wrote lives in the repo and already works for whoever clones,
|
|
241
|
+
with no command at all. Declare it in `.rsc.json` under `ownSkills` and `doctor` will also say
|
|
242
|
+
when someone is missing it — that is all declaring does. rsc never installs, updates or
|
|
243
|
+
overwrites it: its version is the commit.
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
## 🩹 Something's off? One command
|
|
247
|
+
|
|
248
|
+
Recognise any of these? They are all the same fix.
|
|
249
|
+
|
|
250
|
+
| What you see | |
|
|
251
|
+
| --- | --- |
|
|
252
|
+
| `"target": "codex"` when you work in Claude Code | |
|
|
253
|
+
| `This target has no hook injection` and you did not expect that | |
|
|
254
|
+
| Skills appear that you never asked for | |
|
|
255
|
+
| You cloned a repo and your assistant sees no skills at all | |
|
|
256
|
+
| A hook seems to run several times per turn | |
|
|
257
|
+
| Template lines showed up inside your hand-written `AGENTS.md` | |
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
npx @ericrisco/rsc@latest repair
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Safe in any folder: with no rsc there, it says so and writes nothing. It shows what it
|
|
264
|
+
found before touching anything, keeps a recoverable copy, and running it twice changes
|
|
265
|
+
nothing the second time. Add `--dry-run` to see the whole pass without a single write.
|
|
266
|
+
|
|
267
|
+
**What it fixes on its own** — putting the harness back to what was already declared:
|
|
268
|
+
dangling links from a clone, hooks wired several times, the 0.1 layout no assistant reads.
|
|
269
|
+
|
|
270
|
+
**What it asks about** — anything that changes a decision: moving the harness to another
|
|
271
|
+
assistant, or touching files you already committed.
|
|
272
|
+
|
|
273
|
+
**What it never touches:** skills and agents you wrote by hand. rsc did not install them,
|
|
274
|
+
so rsc does not repair, move or delete them — not even when rebuilding from scratch.
|
|
275
|
+
|
|
206
276
|
## Update
|
|
207
277
|
|
|
208
278
|
`rsc` is an npm package, so updating is two steps — bump the package, then
|
package/manifest.json
CHANGED
package/package.json
CHANGED
package/scripts/doctor.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { targetPaths, TARGET_IDS } from '../targets/index.js';
|
|
5
5
|
import { readState } from './lib/state.js';
|
|
6
|
+
import { divergence } from './lib/divergence.js';
|
|
6
7
|
import { loadManifest } from './lib/manifest.js';
|
|
7
8
|
import { listBackups } from './lib/backups.js';
|
|
8
9
|
import { SDD_GATE_TEXT } from '../targets/hook-once.mjs';
|
|
@@ -40,6 +41,40 @@ function selloStatus(root) {
|
|
|
40
41
|
} catch { return { enabled: false }; }
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
// A wired hook is a promise to run a file. `settings.json` existing only proves the
|
|
45
|
+
// promise was made — and a fresh clone brings that file (committed) without .rsc/
|
|
46
|
+
// (ignored), so every session start fails while the report says all-clear. Read the
|
|
47
|
+
// commands we wired and check the files they name actually exist.
|
|
48
|
+
//
|
|
49
|
+
// Hookless targets get an empty list by construction: their always-on surface is a
|
|
50
|
+
// markdown block with no script behind it, so there is nothing that can go missing.
|
|
51
|
+
export function missingHookScripts({ target, home = homedir(), cwd = process.cwd() } = {}) {
|
|
52
|
+
if (HOOKLESS_TARGETS.has(target)) return [];
|
|
53
|
+
const file = targetPaths(target, home, cwd).hookTarget;
|
|
54
|
+
let settings;
|
|
55
|
+
try { settings = JSON.parse(readFileSync(file, 'utf8')); } catch { return []; }
|
|
56
|
+
const commands = [];
|
|
57
|
+
for (const entries of Object.values(settings?.hooks || {})) {
|
|
58
|
+
if (!Array.isArray(entries)) continue;
|
|
59
|
+
for (const entry of entries) for (const h of entry?.hooks || []) {
|
|
60
|
+
if (typeof h?.command === 'string') commands.push(h.command);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
// Commands name the project through ${CLAUDE_PROJECT_DIR}, which only the client
|
|
65
|
+
// expands. Resolve it here or every wired script reads as missing and the report
|
|
66
|
+
// calls a healthy install broken — the mirror image of the bug this check fixes.
|
|
67
|
+
const expand = (c) => c.split('${CLAUDE_PROJECT_DIR}').join(cwd);
|
|
68
|
+
for (const raw of commands) {
|
|
69
|
+
const cmd = expand(raw);
|
|
70
|
+
for (const m of cmd.matchAll(/["']?([^"'\s]*[\\/]\.rsc[\\/][^"'\s]+\.mjs)["']?/g)) {
|
|
71
|
+
const script = m[1];
|
|
72
|
+
if (!existsSync(script)) seen.add(script);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return [...seen];
|
|
76
|
+
}
|
|
77
|
+
|
|
43
78
|
export function doctor({ target, home, cwd }) {
|
|
44
79
|
const root = cwd || process.cwd();
|
|
45
80
|
const paths = targetPaths(target, home, cwd);
|
|
@@ -50,7 +85,8 @@ export function doctor({ target, home, cwd }) {
|
|
|
50
85
|
target,
|
|
51
86
|
installed: Object.keys(state.skills),
|
|
52
87
|
missing: [],
|
|
53
|
-
|
|
88
|
+
// Wired means it can actually run, not merely that the file declaring it is there.
|
|
89
|
+
hookWired: existsSync(paths.hookTarget) && missingHookScripts({ target, home, cwd }).length === 0,
|
|
54
90
|
manifestSkills: manifest.counts.skills,
|
|
55
91
|
backups: {
|
|
56
92
|
exists: existsSync(join(root, '.rsc', 'backups')),
|
|
@@ -285,6 +321,37 @@ export function contextBudget({ target, home = homedir(), cwd = process.cwd() }
|
|
|
285
321
|
+ 'root, or update both to a version that de-duplicates at runtime.',
|
|
286
322
|
});
|
|
287
323
|
}
|
|
324
|
+
const orphanScripts = missingHookScripts({ target, home, cwd });
|
|
325
|
+
if (orphanScripts.length) {
|
|
326
|
+
// ONE finding, not one per file: a clone is missing all eight at once, and a list of
|
|
327
|
+
// eight paths buries the single thing the reader has to do (principle 5).
|
|
328
|
+
findings.push({
|
|
329
|
+
id: 'hook-scripts-missing',
|
|
330
|
+
severity: 'high',
|
|
331
|
+
summary: `${orphanScripts.length} hook script(s) named by the wiring are not on disk, so those hooks `
|
|
332
|
+
+ 'fail every time they fire. This is what a fresh clone looks like: the settings travelled, the '
|
|
333
|
+
+ 'scripts did not.',
|
|
334
|
+
action: 'Rebuild them with `npx @ericrisco/rsc repair` from this project root.',
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
const drift = divergence({ cwd, target, home });
|
|
338
|
+
if (drift.missing.length || drift.extra.length || drift.ownMissing.length) {
|
|
339
|
+
// The day-two case: a teammate changed the harness and this checkout has not caught
|
|
340
|
+
// up. Reported always, even after someone declines to align — the divergence does not
|
|
341
|
+
// stop being true because they said no.
|
|
342
|
+
const parts = [];
|
|
343
|
+
if (drift.missing.length) parts.push(`missing ${drift.missing.join(', ')}`);
|
|
344
|
+
if (drift.ownMissing.length) parts.push(`${drift.ownMissing.join(', ')} declared by the team but not in this repo`);
|
|
345
|
+
if (drift.extra.length) parts.push(`${drift.extra.join(', ')} installed but no longer declared`);
|
|
346
|
+
findings.push({
|
|
347
|
+
id: 'manifest-divergence',
|
|
348
|
+
severity: 'medium',
|
|
349
|
+
summary: `This checkout does not match what .rsc.json declares: ${parts.join('; ')}.`,
|
|
350
|
+
action: drift.ownMissing.length && !drift.missing.length && !drift.extra.length
|
|
351
|
+
? 'Those come from the repo, not from rsc — pull, or ask whoever wrote them.'
|
|
352
|
+
: 'Align with `npx @ericrisco/rsc sync`. Nothing is written until you run it.',
|
|
353
|
+
});
|
|
354
|
+
}
|
|
288
355
|
findings.push(...duplicateEntryFindings(scopes));
|
|
289
356
|
for (const s of scopes) {
|
|
290
357
|
if (s.hookCountsUnknown) {
|
package/scripts/install-apply.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { rmSync, existsSync, cpSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
3
|
-
import { join, dirname, basename } from 'node:path';
|
|
2
|
+
import { rmSync, existsSync, cpSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join, dirname, basename, relative, sep } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { planInstall } from './install-plan.js';
|
|
6
6
|
import { targetPaths, writeSkill, wireHook, unwireHook, baseDir, TARGET_IDS } from '../targets/index.js';
|
|
7
7
|
import { targetHasAgents, writeAgents, removeAgents, agentPath, agentNames } from '../targets/agents.js';
|
|
8
8
|
import { readState, writeState } from './lib/state.js';
|
|
9
|
+
import { readManifest, writeManifest } from './lib/manifest-file.js';
|
|
9
10
|
import { createBackup } from './lib/backups.js';
|
|
10
11
|
|
|
11
12
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -67,7 +68,7 @@ function generatedHookFiles({ target, cwd }) {
|
|
|
67
68
|
];
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
function managedPathsForInstall({ skillIds, target, home, cwd }) {
|
|
71
|
+
export function managedPathsForInstall({ skillIds, target, home, cwd }) {
|
|
71
72
|
const paths = targetPaths(target, home, cwd);
|
|
72
73
|
const plan = planInstall({ skillIds, target, home, cwd });
|
|
73
74
|
const out = [paths.stateFile, versionFile(cwd), baseVersionsFile(cwd)];
|
|
@@ -82,6 +83,43 @@ function managedPathsForInstall({ skillIds, target, home, cwd }) {
|
|
|
82
83
|
return [...new Set(out)];
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
// The opt-outs a team disarmed and the developer tier live as marker files under .rsc/,
|
|
87
|
+
// which is machine-local and therefore lost on every clone: a team that disarmed the
|
|
88
|
+
// gitmoji guard found it armed again on the next checkout, with nobody having decided
|
|
89
|
+
// that. They are decisions, so they belong in the committed declaration.
|
|
90
|
+
function localDecisions(cwd) {
|
|
91
|
+
const dir = join(cwd, '.rsc');
|
|
92
|
+
let optOuts = [];
|
|
93
|
+
try {
|
|
94
|
+
optOuts = readdirSync(dir).filter((f) => f.startsWith('.no-')).map((f) => f.slice(4)).sort();
|
|
95
|
+
} catch { /* no .rsc yet */ }
|
|
96
|
+
let tier = null;
|
|
97
|
+
try { tier = JSON.parse(readFileSync(join(dir, 'developer.json'), 'utf8')).tier ?? null; } catch { /* unset */ }
|
|
98
|
+
return { optOuts, tier };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Record the decision, merging so a second assistant never erases the first: installing
|
|
102
|
+
// into codex on a machine that already had claude is adding, not replacing. Union, sorted
|
|
103
|
+
// where order carries no meaning, so the file stays diffable and a merge conflict stays
|
|
104
|
+
// readable.
|
|
105
|
+
export function recordInManifest({ cwd, target, skillIds, catalogVersion = CLI_VERSION, dropTarget }) {
|
|
106
|
+
const prev = readManifest(cwd) || { targets: [], skills: [], ownSkills: [], optOuts: [] };
|
|
107
|
+
const { optOuts, tier } = localDecisions(cwd);
|
|
108
|
+
const union = (a, b) => [...new Set([...(a || []), ...(b || [])])].sort();
|
|
109
|
+
return writeManifest(cwd, {
|
|
110
|
+
version: 1,
|
|
111
|
+
// Union by default: installing into a second assistant is adding, not replacing.
|
|
112
|
+
// `dropTarget` is the one exception — a MOVE, where leaving the old one declared would
|
|
113
|
+
// have every clone rebuild a harness that was deliberately abandoned.
|
|
114
|
+
targets: union(prev.targets, [target]).filter((t) => t !== dropTarget),
|
|
115
|
+
skills: union(prev.skills, skillIds),
|
|
116
|
+
ownSkills: prev.ownSkills || [],
|
|
117
|
+
catalogVersion,
|
|
118
|
+
tier: tier ?? prev.tier ?? null,
|
|
119
|
+
optOuts: optOuts.length ? optOuts : (prev.optOuts || []),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
85
123
|
export async function applyInstall({ skillIds, target, home, cwd = process.cwd(), operation = 'install', dryRun = false }) {
|
|
86
124
|
const paths = targetPaths(target, home, cwd);
|
|
87
125
|
const plan = planInstall({ skillIds, target, home, cwd });
|
|
@@ -120,7 +158,8 @@ export async function applyInstall({ skillIds, target, home, cwd = process.cwd()
|
|
|
120
158
|
writeState(paths.stateFile, state);
|
|
121
159
|
mkdirSync(dirname(versionFile(cwd)), { recursive: true });
|
|
122
160
|
writeFileSync(versionFile(cwd), CLI_VERSION + '\n');
|
|
123
|
-
|
|
161
|
+
recordInManifest({ cwd, target, skillIds });
|
|
162
|
+
ignoreLocalState(cwd, target);
|
|
124
163
|
return { ...state, backup };
|
|
125
164
|
}
|
|
126
165
|
|
|
@@ -132,23 +171,54 @@ export async function applyInstall({ skillIds, target, home, cwd = process.cwd()
|
|
|
132
171
|
// Additive and idempotent by construction: append one line to an existing
|
|
133
172
|
// .gitignore, never rewrite or reorder it, and never create the file in a directory
|
|
134
173
|
// that is not a git repository.
|
|
135
|
-
export function ignoreLocalState(cwd = process.cwd()) {
|
|
174
|
+
export function ignoreLocalState(cwd = process.cwd(), target) {
|
|
136
175
|
if (!existsSync(join(cwd, '.git'))) return null;
|
|
137
176
|
const gi = join(cwd, '.gitignore');
|
|
138
177
|
let text = '';
|
|
139
178
|
try { text = existsSync(gi) ? readFileSync(gi, 'utf8') : ''; } catch { return null; }
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
179
|
+
|
|
180
|
+
// `.rsc/` is machine state. The per-target skill entries are derived: symlinks here,
|
|
181
|
+
// real copies on Windows, so committing either shape puts two incompatible forms of the
|
|
182
|
+
// same thing into one repo.
|
|
183
|
+
//
|
|
184
|
+
// But `claude` writes into `.claude/skills/` and `cursor` into `.cursor/rules/`, which
|
|
185
|
+
// are SHARED with the user — their own skills and rules live there too. Excluding the
|
|
186
|
+
// whole directory would quietly stop versioning their work, which is the same sin this
|
|
187
|
+
// release exists to fix: treating what is theirs as if it were ours. So shared
|
|
188
|
+
// directories are excluded entry by entry, and only the entries rsc manages.
|
|
189
|
+
const wanted = ['.rsc/'];
|
|
190
|
+
if (target) {
|
|
191
|
+
const paths = targetPaths(target, undefined, cwd);
|
|
192
|
+
const rel = (abs) => relative(cwd, abs).split(sep).join('/');
|
|
193
|
+
for (const id of Object.keys(readState(paths.stateFile).skills || {})) wanted.push(rel(paths.skillDir(id)));
|
|
194
|
+
// The per-file ledger is derived AND platform-shaped (symlink here, real copy on
|
|
195
|
+
// Windows), so committing it puts two incompatible inventories of one thing in one
|
|
196
|
+
// repo. The declaration that does travel is .rsc.json, in the root.
|
|
197
|
+
wanted.push(rel(paths.stateFile));
|
|
198
|
+
// Subagents are catalog content, re-materialised by every install and sync.
|
|
199
|
+
for (const f of agentNames()) wanted.push(rel(agentPath(target, cwd, f)));
|
|
200
|
+
}
|
|
201
|
+
// `.rsc`, `/.rsc` and `.rsc/` are the same rule to git. Normalising both ends is what
|
|
202
|
+
// keeps this idempotent against a .gitignore a human wrote in their own spelling.
|
|
203
|
+
const norm = (l) => l.trim().replace(/^\//, '').replace(/\/$/, '');
|
|
204
|
+
const present = new Set(text.split('\n').map(norm));
|
|
205
|
+
const add = wanted.filter((w) => !present.has(norm(w)));
|
|
206
|
+
if (!add.length) return null;
|
|
207
|
+
|
|
208
|
+
// Append only. Never rewrite, never reorder: the rest of that file is theirs.
|
|
145
209
|
const prefix = text === '' ? '' : (text.endsWith('\n') ? '' : '\n');
|
|
146
210
|
try {
|
|
147
|
-
appendFileSync(gi, `${prefix}\n# rsc local state (hooks, seals, logs) — machine-local
|
|
211
|
+
appendFileSync(gi, `${prefix}\n# rsc local state (hooks, seals, logs) and managed skill links — machine-local\n${add.join('\n')}\n`);
|
|
148
212
|
return gi;
|
|
149
213
|
} catch { return null; }
|
|
150
214
|
}
|
|
151
215
|
|
|
216
|
+
export function collisions({ cwd = process.cwd(), target, home, skillIds = [] }) {
|
|
217
|
+
const paths = targetPaths(target, home, cwd);
|
|
218
|
+
const managed = new Set(Object.keys(readState(paths.stateFile).skills || {}));
|
|
219
|
+
return skillIds.filter((id) => !managed.has(id) && existsSync(paths.skillDir(id)));
|
|
220
|
+
}
|
|
221
|
+
|
|
152
222
|
export function listInstalled({ target, home, cwd = process.cwd() }) {
|
|
153
223
|
const paths = targetPaths(target, home, cwd);
|
|
154
224
|
return Object.keys(readState(paths.stateFile).skills);
|
|
@@ -185,17 +255,22 @@ export async function uninstall({ skillIds, target, home, cwd = process.cwd(), d
|
|
|
185
255
|
export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun = false }) {
|
|
186
256
|
const paths = targetPaths(target, home, cwd);
|
|
187
257
|
const state = readState(paths.stateFile);
|
|
258
|
+
// The per-target state is machine wiring and does not travel, so a fresh clone has
|
|
259
|
+
// none of it: sync found zero skills and did nothing, leaving someone with a repo that
|
|
260
|
+
// declares a harness and has none. The committed manifest is exactly the declaration
|
|
261
|
+
// to rebuild from, and it is sitting right there.
|
|
188
262
|
const ids = Object.keys(state.skills || {});
|
|
189
|
-
|
|
263
|
+
const declared = ids.length ? ids : (readManifest(cwd)?.skills || []);
|
|
264
|
+
if (!declared.length) return dryRun ? { dryRun: true, synced: [], paths: [] } : { synced: [], backup: null };
|
|
190
265
|
if (dryRun) {
|
|
191
266
|
return {
|
|
192
267
|
dryRun: true,
|
|
193
|
-
synced:
|
|
194
|
-
paths: managedPathsForInstall({ skillIds:
|
|
268
|
+
synced: declared,
|
|
269
|
+
paths: managedPathsForInstall({ skillIds: declared, target, home, cwd }),
|
|
195
270
|
};
|
|
196
271
|
}
|
|
197
|
-
const nextState = await applyInstall({ skillIds:
|
|
198
|
-
return { synced:
|
|
272
|
+
const nextState = await applyInstall({ skillIds: declared, target, home, cwd, operation: 'sync' });
|
|
273
|
+
return { synced: declared, backup: nextState.backup };
|
|
199
274
|
}
|
|
200
275
|
|
|
201
276
|
// Remove EVERYTHING rsc put in this project: installed skills across all targets,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readManifest } from './manifest-file.js';
|
|
2
|
+
import { readState } from './state.js';
|
|
3
|
+
import { targetPaths } from '../../targets/index.js';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
// What the team declared versus what is on this machine.
|
|
7
|
+
//
|
|
8
|
+
// The everyday case is not cloning — it is a teammate changing the harness and everyone
|
|
9
|
+
// else running `git pull`. The clone is day one; divergence is every other day. Nothing
|
|
10
|
+
// used to notice, so two people drifted apart in silence.
|
|
11
|
+
//
|
|
12
|
+
// Three buckets, because the three need different answers, and collapsing them is how a
|
|
13
|
+
// report starts nagging someone about their own work:
|
|
14
|
+
// missing — declared catalog skill, not installed here. Aligning installs it.
|
|
15
|
+
// ownMissing — declared team skill, absent from the repo. Aligning says so and writes
|
|
16
|
+
// nothing: it comes from the repo, not from us.
|
|
17
|
+
// extra — installed BY RSC, no longer declared.
|
|
18
|
+
// A hand-written skill that nobody declared is in none of them. Not declaring is a valid
|
|
19
|
+
// way to keep things of your own, not an oversight to correct.
|
|
20
|
+
export function divergence({ cwd = process.cwd(), target, home } = {}) {
|
|
21
|
+
const empty = { missing: [], extra: [], ownMissing: [] };
|
|
22
|
+
const manifest = readManifest(cwd);
|
|
23
|
+
if (!manifest) return empty;
|
|
24
|
+
|
|
25
|
+
const paths = targetPaths(target, home, cwd);
|
|
26
|
+
const installed = new Set(Object.keys(readState(paths.stateFile).skills || {}));
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
missing: (manifest.skills || []).filter((id) => !installed.has(id)),
|
|
30
|
+
extra: [...installed].filter((id) => !(manifest.skills || []).includes(id)),
|
|
31
|
+
ownMissing: (manifest.ownSkills || []).filter((name) => !existsSync(paths.skillDir(name))),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// `.rsc.json` — the team's decision about this project's harness, committed.
|
|
5
|
+
//
|
|
6
|
+
// It exists because nothing else could travel. The per-target state lives inside the
|
|
7
|
+
// assistant's own directory, which is machine wiring; `syncInstalled()` reads it, so a
|
|
8
|
+
// clone finds zero skills and does nothing. The declaration had to move to the root.
|
|
9
|
+
//
|
|
10
|
+
// It is a DECLARATION, never an inventory: ids and names only, no paths and no file
|
|
11
|
+
// lists. Two reasons. Paths differ by platform (some machines symlink, others copy), so
|
|
12
|
+
// committing them reproduces in the manifest the very conflict this removes from the
|
|
13
|
+
// skill directories. And a committed file gets merge conflicts by nature — one entry per
|
|
14
|
+
// line, stable key order, so resolving one means reading it, not understanding a format.
|
|
15
|
+
export const MANIFEST = '.rsc.json';
|
|
16
|
+
export const manifestPath = (cwd = process.cwd()) => join(cwd, MANIFEST);
|
|
17
|
+
|
|
18
|
+
const KEYS = ['version', 'targets', 'skills', 'ownSkills', 'catalogVersion', 'tier', 'optOuts'];
|
|
19
|
+
|
|
20
|
+
export function readManifest(cwd = process.cwd()) {
|
|
21
|
+
const file = manifestPath(cwd);
|
|
22
|
+
if (!existsSync(file)) return null;
|
|
23
|
+
let raw;
|
|
24
|
+
// An unreadable manifest is NOT the same as an absent one. Absent means "not adopted
|
|
25
|
+
// yet" and is normal; corrupt means the file that governs this project cannot be
|
|
26
|
+
// trusted, and silently treating it as absent would then overwrite it on the next
|
|
27
|
+
// install. Say it and stop.
|
|
28
|
+
try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch {
|
|
29
|
+
throw new Error(`${MANIFEST} exists but is not readable JSON — fix or delete it; rsc will not overwrite it`);
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
version: raw.version ?? 1,
|
|
33
|
+
targets: raw.targets || [],
|
|
34
|
+
skills: raw.skills || [],
|
|
35
|
+
ownSkills: raw.ownSkills || [],
|
|
36
|
+
catalogVersion: raw.catalogVersion ?? null,
|
|
37
|
+
tier: raw.tier ?? null,
|
|
38
|
+
optOuts: raw.optOuts || [],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Stable output: fixed key order, arrays one entry per line, order as given. Rewriting an
|
|
43
|
+
// unchanged manifest must produce identical bytes, or every install shows up as a diff.
|
|
44
|
+
export function writeManifest(cwd, manifest) {
|
|
45
|
+
const out = {};
|
|
46
|
+
for (const k of KEYS) if (manifest[k] !== undefined) out[k] = manifest[k];
|
|
47
|
+
out.version ??= 1;
|
|
48
|
+
const ordered = {};
|
|
49
|
+
for (const k of KEYS) if (out[k] !== undefined) ordered[k] = out[k];
|
|
50
|
+
const file = manifestPath(cwd);
|
|
51
|
+
writeFileSync(file, JSON.stringify(ordered, null, 2) + '\n');
|
|
52
|
+
return file;
|
|
53
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { targetPaths, installedTargets, detectTarget, unwireHook } from '../../targets/index.js';
|
|
5
|
+
import { readState } from './state.js';
|
|
6
|
+
import { readManifest } from './manifest-file.js';
|
|
7
|
+
import { createBackup } from './backups.js';
|
|
8
|
+
import { countHookEntries } from '../doctor.js';
|
|
9
|
+
import { applyInstall, recordInManifest, managedPathsForInstall } from '../install-apply.js';
|
|
10
|
+
|
|
11
|
+
// Fixing a harness that came from an earlier version.
|
|
12
|
+
//
|
|
13
|
+
// The other two deliveries fix the future and fix nobody: everyone who built a harness on
|
|
14
|
+
// 0.1 or 1.0 wakes up the day after with exactly what they had, and does not know it. This
|
|
15
|
+
// is the one command for them — no flags, no need to know what is wrong.
|
|
16
|
+
//
|
|
17
|
+
// EVERY finding is born with its class, and the executor only reads that field. It never
|
|
18
|
+
// decides:
|
|
19
|
+
//
|
|
20
|
+
// restore — put the harness back to what was ALREADY declared. Nobody decides anything,
|
|
21
|
+
// so it happens on its own, after a recoverable copy.
|
|
22
|
+
// change — alter something a person or a team chose. Always asked, one by one.
|
|
23
|
+
//
|
|
24
|
+
// Putting the boundary in the data instead of the executor means it can be tested without
|
|
25
|
+
// running anything, and it cannot drift apart between branches.
|
|
26
|
+
|
|
27
|
+
const finding = (id, cls, summary, action) => ({ id, class: cls, summary, action });
|
|
28
|
+
|
|
29
|
+
export function diagnose({ cwd = process.cwd(), target, home = homedir(), invoked = false } = {}) {
|
|
30
|
+
const paths = targetPaths(target, home, cwd);
|
|
31
|
+
const state = readState(paths.stateFile);
|
|
32
|
+
const installed = Object.keys(state.skills || {});
|
|
33
|
+
const manifest = readManifest(cwd);
|
|
34
|
+
const out = [];
|
|
35
|
+
|
|
36
|
+
// Nothing of ours here at all: say so and touch nothing. Running this in the wrong
|
|
37
|
+
// folder must be harmless.
|
|
38
|
+
if (!installed.length && !manifest && !existsSync(join(cwd, '.rsc'))) return out;
|
|
39
|
+
|
|
40
|
+
// The 0.1 layout Claude Code never discovered. writeSkill() sweeps it, but only when
|
|
41
|
+
// that skill is reinstalled — so anyone who never reinstalled still carries it.
|
|
42
|
+
if (existsSync(join(paths.root, 'rsc'))) {
|
|
43
|
+
out.push(finding('nested-layout', 'restore',
|
|
44
|
+
'A .claude/skills/rsc/ directory from the 0.1 layout is still here; no assistant ever read it.',
|
|
45
|
+
'It gets removed. Nothing that works today lives in it.'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Repeated wiring. On Windows the needle never matched, so every install ADDED a hook
|
|
49
|
+
// instead of replacing it: there were real machines running each hook four times.
|
|
50
|
+
const repeated = duplicatedHooks(paths.hookTarget);
|
|
51
|
+
if (repeated.length) {
|
|
52
|
+
out.push(finding('duplicate-hooks', 'restore',
|
|
53
|
+
`${repeated.join(', ')} wired more than once, so those hooks run several times per event.`,
|
|
54
|
+
'The extra copies are removed, leaving one of each.'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A clone: the declaration travelled, the materialised skills did not.
|
|
58
|
+
const dangling = installed.filter((id) => !existsSync(paths.skillDir(id)))
|
|
59
|
+
.concat((manifest?.skills || []).filter((id) => !existsSync(paths.skillDir(id))));
|
|
60
|
+
if (dangling.length) {
|
|
61
|
+
out.push(finding('dangling-links', 'restore',
|
|
62
|
+
`${[...new Set(dangling)].join(', ')} are declared but not on disk — what a fresh clone looks like.`,
|
|
63
|
+
'They get rebuilt from the catalog at the version the manifest pins.'));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Writing the manifest records something already true, which sounds like a restoration.
|
|
67
|
+
// But it makes a new committable file appear in someone's root, and that surprises —
|
|
68
|
+
// and surprising people is what started all of this. So: automatic when they typed the
|
|
69
|
+
// command (they already said yes), asked when we are the ones who noticed.
|
|
70
|
+
if (!manifest && installed.length) {
|
|
71
|
+
out.push(finding('no-manifest', invoked ? 'restore' : 'change',
|
|
72
|
+
'This harness predates .rsc.json, so it cannot be shared by git yet.',
|
|
73
|
+
'A manifest is written from what is already installed here.'));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Installed somewhere the project does not look like. This is #249: `harness` writes an
|
|
77
|
+
// AGENTS.md, detection read it as codex, and people ended up wired to an assistant they
|
|
78
|
+
// never chose. Moving is a change — it is where the harness lives.
|
|
79
|
+
const others = installedTargets(cwd).filter((t) => t !== target);
|
|
80
|
+
const looksLike = detectTarget(cwd);
|
|
81
|
+
if (!others.length && installed.length && looksLike !== target && !manifest) {
|
|
82
|
+
out.push(finding('wrong-target', 'change',
|
|
83
|
+
`The harness is installed for ${target}, but this project looks like ${looksLike}.`,
|
|
84
|
+
`It moves to ${looksLike}, and the old wiring — including the block in the root file — is removed.`),
|
|
85
|
+
);
|
|
86
|
+
out[out.length - 1].to = looksLike;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Remove the old wiring's skills. The list comes from the target's OWN state, which by
|
|
92
|
+
// construction holds only what rsc installed — so "we never touch what we did not write"
|
|
93
|
+
// is a property of the input here, not a check that could be forgotten or drift.
|
|
94
|
+
//
|
|
95
|
+
// It was a check, once, sitting on a list that mixed state and manifest ids. A mutant that
|
|
96
|
+
// deleted the check killed no test, and chasing that revealed the branch was unreachable
|
|
97
|
+
// anyway. A guard that cannot be seen to fail is worse than no guard (principle 2), so it
|
|
98
|
+
// became structure instead.
|
|
99
|
+
function removeManagedSkills(paths) {
|
|
100
|
+
for (const id of Object.keys(readState(paths.stateFile).skills || {})) {
|
|
101
|
+
rmSync(paths.skillDir(id), { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
rmSync(paths.stateFile, { force: true });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function duplicatedHooks(hookTarget) {
|
|
107
|
+
let settings;
|
|
108
|
+
try { settings = JSON.parse(readFileSync(hookTarget, 'utf8')); } catch { return []; }
|
|
109
|
+
const { perEvent } = countHookEntries(settings);
|
|
110
|
+
const dup = [];
|
|
111
|
+
for (const [event, ids] of Object.entries(perEvent)) {
|
|
112
|
+
for (const [id, n] of Object.entries(ids)) if (n > 1) dup.push(`${event}/${id}`);
|
|
113
|
+
}
|
|
114
|
+
return dup;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function repair({ cwd = process.cwd(), target, home = homedir(), dryRun = false, invoked = false, accept } = {}) {
|
|
118
|
+
const found = diagnose({ cwd, target, home, invoked });
|
|
119
|
+
const restores = found.filter((f) => f.class === 'restore');
|
|
120
|
+
const changes = found.filter((f) => f.class === 'change');
|
|
121
|
+
|
|
122
|
+
// Anything the person has to decide is only applied if they actually said yes. With
|
|
123
|
+
// nobody to ask, it stays pending — and the restorations still get done, because they
|
|
124
|
+
// never depended on that decision.
|
|
125
|
+
const okChanges = [];
|
|
126
|
+
for (const c of changes) if (accept && await accept(c)) okChanges.push(c);
|
|
127
|
+
const todo = [...restores, ...okChanges];
|
|
128
|
+
if (!todo.length || dryRun) {
|
|
129
|
+
return { applied: todo, pending: changes.filter((c) => !okChanges.includes(c)), backup: null };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const paths = targetPaths(target, home, cwd);
|
|
133
|
+
const ids = [...new Set([...Object.keys(readState(paths.stateFile).skills || {}), ...(readManifest(cwd)?.skills || [])])];
|
|
134
|
+
const backup = createBackup({
|
|
135
|
+
cwd, operation: 'repair', target,
|
|
136
|
+
paths: managedPathsForInstall({ skillIds: ids, target, home, cwd }),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
for (const f of todo) {
|
|
140
|
+
if (f.id === 'nested-layout') rmSync(join(paths.root, 'rsc'), { recursive: true, force: true });
|
|
141
|
+
if (f.id === 'duplicate-hooks') { unwireHook(target, paths); await applyInstall({ skillIds: ids, target, home, cwd, operation: 'repair' }); }
|
|
142
|
+
if (f.id === 'dangling-links') await applyInstall({ skillIds: ids, target, home, cwd, operation: 'repair' });
|
|
143
|
+
if (f.id === 'no-manifest') recordInManifest({ cwd, target, skillIds: ids });
|
|
144
|
+
if (f.id === 'wrong-target') {
|
|
145
|
+
// Install where the project actually points, THEN take the old wiring down — in that
|
|
146
|
+
// order, so a failure halfway leaves a working harness rather than none. Taking the
|
|
147
|
+
// block out of the old file gives it back byte-identical: in #249 that file was the
|
|
148
|
+
// project's hand-written constitution, and it had already been written into once.
|
|
149
|
+
await applyInstall({ skillIds: ids, target: f.to, home, cwd, operation: 'repair' });
|
|
150
|
+
unwireHook(target, paths);
|
|
151
|
+
removeManagedSkills(paths);
|
|
152
|
+
recordInManifest({ cwd, target: f.to, skillIds: ids, dropTarget: target });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { applied: todo, pending: changes.filter((c) => !okChanges.includes(c)), backup };
|
|
156
|
+
}
|
package/scripts/lib/spec-gate.js
CHANGED
|
@@ -34,6 +34,10 @@ const UNCHECKED = [
|
|
|
34
34
|
'que ninguna sección contenga una suposición sin marcar (no es detectable por máquina; es el juicio que la fase existe para ejercer)',
|
|
35
35
|
'que el contenido de cada sección sea correcto, y no sólo presente',
|
|
36
36
|
'que los criterios de aceptación sean de verdad binarios',
|
|
37
|
+
// Added with the status check below, for the same reason the three above exist: a green that is
|
|
38
|
+
// narrower than the rule it appears to enforce is the decorative gate all over again.
|
|
39
|
+
'si una spec dice "implementada" sin nombrar commit, PR ni versión (no hay nada que ir a mirar)',
|
|
40
|
+
'si el trabajo de una spec que se declara sin aterrizar llegó por otra vía (git no lo distingue)',
|
|
37
41
|
];
|
|
38
42
|
|
|
39
43
|
function fold(s) {
|
|
@@ -120,3 +124,97 @@ export function specCompleteness(markdown) {
|
|
|
120
124
|
unchecked: UNCHECKED,
|
|
121
125
|
};
|
|
122
126
|
}
|
|
127
|
+
|
|
128
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
// Does the status still match the repository?
|
|
130
|
+
//
|
|
131
|
+
// The section check above asks whether a spec is COMPLETE. This asks whether it is still TRUE — and
|
|
132
|
+
// nothing asked that before, which is how seven of twenty-five statuses came to contradict the repo:
|
|
133
|
+
// one claiming "sin push ni PR" over work that had been in main for weeks, four sitting in
|
|
134
|
+
// `awaiting-approval` over work already published. Spec: 02-DOCS/wiki/sdd/specs/spec-status-drift.md
|
|
135
|
+
//
|
|
136
|
+
// The vocabulary below was extracted from the 25 real statuses, not imagined. That matters: an
|
|
137
|
+
// invented recognizer reaches for the bare word `implementada`, and the bare word is not checkable —
|
|
138
|
+
// it names nothing to go and look for. Only a commit, a PR or a released version do.
|
|
139
|
+
|
|
140
|
+
/** The claim shapes this recognizer knows. Anything else is silence, not a finding. */
|
|
141
|
+
export const CLAIM_KINDS = ['sha', 'pr', 'version', 'not-landed'];
|
|
142
|
+
|
|
143
|
+
// Deliberately absent: the bare words `implementada`, `desplegada`, `descartada`, and the phrase
|
|
144
|
+
// `no aprobada`. The first two name nothing to check. The last two look like landing claims and are
|
|
145
|
+
// not — `descartada` is a spec closed on purpose after being measured, `no aprobada` describes HOW
|
|
146
|
+
// approval happened (in autopilot, not item by item), not whether code shipped. Treating either as a
|
|
147
|
+
// landing claim would invent drift in the two best-closed specs of the set.
|
|
148
|
+
const SHA = /\b[0-9a-f]{7,40}\b/g;
|
|
149
|
+
const PR = /\bPR\s*#(\d+)/gi;
|
|
150
|
+
const VERSION = /\bpublicada en (\d+\.\d+\.\d+)/gi;
|
|
151
|
+
const NOT_LANDED = /\bsin push\b|\bsin PR\b|\bawaiting-approval\b/i;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Pull the checkable claims out of a status string. Pure: no git, no I/O.
|
|
155
|
+
* @param {string} statusText
|
|
156
|
+
* @returns {Array<{kind: string, value: string, raw: string}>}
|
|
157
|
+
*/
|
|
158
|
+
export function statusClaims(statusText) {
|
|
159
|
+
const text = typeof statusText === 'string' ? statusText : '';
|
|
160
|
+
const claims = [];
|
|
161
|
+
for (const m of text.matchAll(PR)) claims.push({ kind: 'pr', value: m[1], raw: m[0] });
|
|
162
|
+
for (const m of text.matchAll(VERSION)) claims.push({ kind: 'version', value: m[1], raw: m[0] });
|
|
163
|
+
for (const m of text.matchAll(SHA)) claims.push({ kind: 'sha', value: m[0], raw: m[0] });
|
|
164
|
+
const neg = NOT_LANDED.exec(text);
|
|
165
|
+
if (neg) claims.push({ kind: 'not-landed', value: neg[0], raw: neg[0] });
|
|
166
|
+
return claims;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Judge each claim against the repository.
|
|
171
|
+
*
|
|
172
|
+
* Four verdicts and none of them collapse into another. `unverifiable` is the one that keeps this
|
|
173
|
+
* honest — a claim we could not check is not a claim that passed, and it is not drift either.
|
|
174
|
+
*
|
|
175
|
+
* `not-landed` is always handed to a human, and that is a deliberate retreat from what the spec
|
|
176
|
+
* first promised. No query distinguishes "never landed" from "landed by another route": the case
|
|
177
|
+
* that started this work names a commit that genuinely is not in main, while its deliverable has
|
|
178
|
+
* been there for weeks. Checking the named commit would bless the misleading status; inferring the
|
|
179
|
+
* other from anything else would be a heuristic inside a gate. So the deterministic part narrows,
|
|
180
|
+
* and only what it narrowed goes to judgement — principle 1, and the shape `knowledge-drift` already
|
|
181
|
+
* uses.
|
|
182
|
+
*
|
|
183
|
+
* @param {Array<object>} claims from `statusClaims`
|
|
184
|
+
* @param {?{hasCommit: Function, isAncestor: Function, hasPr: Function, hasTag: Function}} probe
|
|
185
|
+
* null when there is no repository to ask — every claim then reports unverifiable.
|
|
186
|
+
* @returns {Array<{kind: string, value: string, verdict: string, reason: string}>}
|
|
187
|
+
*/
|
|
188
|
+
export function checkClaims(claims, probe) {
|
|
189
|
+
return (claims || []).map((c) => {
|
|
190
|
+
if (c.kind === 'not-landed') {
|
|
191
|
+
return {
|
|
192
|
+
...c,
|
|
193
|
+
verdict: 'needs-human',
|
|
194
|
+
reason: 'git cannot tell "never landed" from "landed by another route" — look at whether the deliverable is in main, not at the branch',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (!probe) return { ...c, verdict: 'unverifiable', reason: 'no repository to ask' };
|
|
198
|
+
try {
|
|
199
|
+
if (c.kind === 'sha') {
|
|
200
|
+
if (!probe.hasCommit(c.value)) {
|
|
201
|
+
return { ...c, verdict: 'unverifiable', reason: `commit ${c.value} is not in this repository` };
|
|
202
|
+
}
|
|
203
|
+
return probe.isAncestor(c.value)
|
|
204
|
+
? { ...c, verdict: 'holds', reason: `${c.value} is in main` }
|
|
205
|
+
: { ...c, verdict: 'stale', reason: `${c.value} is NOT in main` };
|
|
206
|
+
}
|
|
207
|
+
if (c.kind === 'pr') {
|
|
208
|
+
return probe.hasPr(c.value)
|
|
209
|
+
? { ...c, verdict: 'holds', reason: `PR #${c.value} is merged into main` }
|
|
210
|
+
: { ...c, verdict: 'stale', reason: `no merge of PR #${c.value} in main` };
|
|
211
|
+
}
|
|
212
|
+
return probe.hasTag(c.value)
|
|
213
|
+
? { ...c, verdict: 'holds', reason: `v${c.value} is tagged` }
|
|
214
|
+
: { ...c, verdict: 'stale', reason: `no v${c.value} tag in this repository` };
|
|
215
|
+
} catch (e) {
|
|
216
|
+
// Could not look. Saying `stale` here would invent a finding out of our own failure.
|
|
217
|
+
return { ...c, verdict: 'unverifiable', reason: e.message };
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
package/scripts/lib/ui.js
CHANGED
|
@@ -29,6 +29,11 @@ function interactive() {
|
|
|
29
29
|
return Boolean(stdin.isTTY && stdout.isTTY);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// The one criterion for "is a human here to answer". Exported so target resolution
|
|
33
|
+
// asks the same question the TUI already asks — two criteria for one question is how
|
|
34
|
+
// a terminal run and a CI run drift apart.
|
|
35
|
+
export const isInteractive = interactive;
|
|
36
|
+
|
|
32
37
|
const C = {
|
|
33
38
|
dim: (s) => `\x1b[2m${s}\x1b[22m`,
|
|
34
39
|
bold: (s) => `\x1b[1m${s}\x1b[22m`,
|
package/scripts/rsc.js
CHANGED
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { loadManifest, skillsForProfile } from './lib/manifest.js';
|
|
3
|
-
import { detectTarget, TARGETS } from '../targets/index.js';
|
|
3
|
+
import { detectTarget, 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
|
-
import { applyInstall, listInstalled, uninstall, syncInstalled, purge } from './install-apply.js';
|
|
7
|
+
import { applyInstall, listInstalled, uninstall, syncInstalled, purge, collisions } from './install-apply.js';
|
|
8
8
|
import { doctor } from './doctor.js';
|
|
9
|
-
import { say, select, pickFrom, banner, confirm } from './lib/ui.js';
|
|
9
|
+
import { say, select, pickFrom, banner, confirm, isInteractive } from './lib/ui.js';
|
|
10
10
|
import { refreshRegistry, registryStatus } from './lib/registry.js';
|
|
11
11
|
import { audit, writeAuditReport } from './audit.js';
|
|
12
12
|
import { DOMAINS } from './lib/domains.js';
|
|
13
13
|
import { listBackups, restoreBackup } from './lib/backups.js';
|
|
14
14
|
import { runUpgrade } from './lib/upgrade.js';
|
|
15
|
+
import { diagnose, repair } from './lib/repair.js';
|
|
15
16
|
import { DEFAULT_SKILL_FLOOR, withDefaultSkillFloor } from './lib/default-skill-floor.js';
|
|
16
17
|
|
|
17
18
|
const argv = process.argv.slice(2);
|
|
18
19
|
const cmd = argv[0];
|
|
19
20
|
|
|
21
|
+
const targetLabel = (id) => TARGETS.find((t) => t.id === id)?.label || id;
|
|
22
|
+
|
|
23
|
+
// Commands that act ON one assistant. Only these stop when two are installed and no
|
|
24
|
+
// flag says which: `purge` sweeps every target by design, and the catalog/consult
|
|
25
|
+
// side barely touches one, so blocking them would be a regression, not a safeguard.
|
|
26
|
+
const NEEDS_TARGET = new Set([
|
|
27
|
+
'add', 'install', 'list', 'doctor', 'sync', 'uninstall', 'capabilities', 'catalog', 'registry',
|
|
28
|
+
]);
|
|
29
|
+
|
|
20
30
|
function flag(name) {
|
|
21
31
|
const i = argv.indexOf(`--${name}`);
|
|
22
32
|
return i >= 0 ? (argv[i + 1] || true) : undefined;
|
|
@@ -157,7 +167,7 @@ function printContextBudget(b) {
|
|
|
157
167
|
say('════════════════════════════════════════════════\n');
|
|
158
168
|
}
|
|
159
169
|
|
|
160
|
-
async function wizard() {
|
|
170
|
+
async function wizard(flagTargets) {
|
|
161
171
|
const m = loadManifest();
|
|
162
172
|
await banner(m.counts.skills);
|
|
163
173
|
say(' the skill catalog for your assistant (Claude Code · Codex · Cursor · Gemini · Antigravity)\n');
|
|
@@ -190,7 +200,7 @@ async function wizard() {
|
|
|
190
200
|
continue;
|
|
191
201
|
}
|
|
192
202
|
|
|
193
|
-
const targets = await selectAgents();
|
|
203
|
+
const targets = flagTargets || await selectAgents();
|
|
194
204
|
if (targets === null) continue; // esc in the assistant picker → back to menu
|
|
195
205
|
say(`\nI'll install ${ids.length} skills for: ${targets.join(', ')}`);
|
|
196
206
|
say(' ' + ids.join(', '));
|
|
@@ -209,16 +219,54 @@ async function wizard() {
|
|
|
209
219
|
}
|
|
210
220
|
}
|
|
211
221
|
|
|
222
|
+
// Installing replaces whatever sits where a skill goes. If that is something the user
|
|
223
|
+
// wrote by hand, it is the one thing an install can destroy — so it is named first and
|
|
224
|
+
// they decide. Silence here is what made someone lose three months of work with a backup
|
|
225
|
+
// they never knew to restore.
|
|
226
|
+
async function guardCollisions(targets, ids) {
|
|
227
|
+
const hit = [...new Set(targets.flatMap((t) => collisions({ target: t, skillIds: ids })))];
|
|
228
|
+
if (!hit.length) return true;
|
|
229
|
+
say(`\n⚠️ These are already here and rsc did not put them — they look like your own work:`);
|
|
230
|
+
for (const id of hit) say(` ${id}`);
|
|
231
|
+
say(' Installing would replace them. A backup is kept, but you would have to know to restore it.');
|
|
232
|
+
if (!isInteractive()) {
|
|
233
|
+
console.error('rsc: refusing to overwrite hand-written skills. Rename them, or re-run with --force.');
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
return confirm('Replace them anyway?');
|
|
238
|
+
}
|
|
239
|
+
|
|
212
240
|
async function main() {
|
|
213
|
-
//
|
|
241
|
+
// One resolution for every command, doctor included: `doctor` used to resolve on its
|
|
242
|
+
// own and could report a different assistant than the one just installed into.
|
|
243
|
+
// --target accepts one id or a comma list (e.g. --target claude,codex).
|
|
214
244
|
const f = flag('target');
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
245
|
+
const resolved = resolveTargets({ flagValue: typeof f === 'string' ? f : undefined });
|
|
246
|
+
let targets = resolved.ids;
|
|
247
|
+
if (resolved.ambiguous && NEEDS_TARGET.has(cmd)) {
|
|
248
|
+
// Two installations, no flag: the whole point of this release is that we do NOT pick.
|
|
249
|
+
if (isInteractive()) {
|
|
250
|
+
const picked = await pickFrom(
|
|
251
|
+
'Two assistants are installed here. Which one do you mean?',
|
|
252
|
+
resolved.ambiguous.map((id) => ({ id, label: `${targetLabel(id)} (${listInstalled({ target: id }).length} skills)` })),
|
|
253
|
+
);
|
|
254
|
+
if (!picked || !picked.length) return void say('Cancelled — nothing was touched.');
|
|
255
|
+
targets = picked;
|
|
256
|
+
} else {
|
|
257
|
+
console.error(
|
|
258
|
+
`rsc: ${resolved.ambiguous.join(' and ')} are both installed here, so I will not guess.\n` +
|
|
259
|
+
` Say which one: npx @ericrisco/rsc ${cmd} --target ${resolved.ambiguous[0]}`,
|
|
260
|
+
);
|
|
261
|
+
process.exitCode = 1;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (!targets.length) targets = [detectTarget()];
|
|
218
266
|
const target = targets[0];
|
|
219
267
|
switch (cmd) {
|
|
220
268
|
case undefined:
|
|
221
|
-
return wizard();
|
|
269
|
+
return wizard(f ? targets : null);
|
|
222
270
|
case 'add': {
|
|
223
271
|
// Positional args = skill ids; skip flags and any flag value (the token after a --flag).
|
|
224
272
|
const requested = [];
|
|
@@ -227,6 +275,7 @@ async function main() {
|
|
|
227
275
|
requested.push(argv[i]);
|
|
228
276
|
}
|
|
229
277
|
const ids = withDefaultSkillFloor(requested);
|
|
278
|
+
if (!argv.includes('--force') && !(await guardCollisions(targets, ids))) return;
|
|
230
279
|
for (const t of targets) await applyInstall({ skillIds: ids, target: t });
|
|
231
280
|
say(`✅ Installed for ${targets.join(', ')}: ${requested.join(', ')}`);
|
|
232
281
|
return void say(' ↻ Reload/restart your assistant so the new skill activates.');
|
|
@@ -236,6 +285,7 @@ async function main() {
|
|
|
236
285
|
const without = argv.filter((a, i) => argv[i - 1] === '--without');
|
|
237
286
|
let ids = skillsForProfile(loadManifest(), profile);
|
|
238
287
|
ids = withDefaultSkillFloor(ids).filter((id) => !without.includes(id));
|
|
288
|
+
if (!argv.includes('--force') && !(await guardCollisions(targets, ids))) return;
|
|
239
289
|
for (const t of targets) await applyInstall({ skillIds: ids, target: t });
|
|
240
290
|
say(`✅ Profile '${profile}' installed for ${targets.join(', ')} (${ids.length} skills)`);
|
|
241
291
|
printAgentHandoff();
|
|
@@ -577,11 +627,34 @@ async function main() {
|
|
|
577
627
|
const removed = await uninstall({ skillIds: ids, target, dryRun: dry });
|
|
578
628
|
return void say((dry ? 'Would remove:\n' : 'Removed:\n') + (removed.join('\n') || '(nothing)'));
|
|
579
629
|
}
|
|
630
|
+
case 'repair': {
|
|
631
|
+
// One command, no flags, for someone who does not know what is wrong. Restorations
|
|
632
|
+
// happen on their own; anything that changes a decision is asked, one by one.
|
|
633
|
+
const dry = argv.includes('--dry-run');
|
|
634
|
+
const yes = argv.includes('--yes');
|
|
635
|
+
const found = diagnose({ target, invoked: true });
|
|
636
|
+
if (!found.length) return void say('Nothing to repair — this harness is healthy.');
|
|
637
|
+
say(`\nFound ${found.length} thing(s):\n`);
|
|
638
|
+
for (const f of found) say(` [${f.class === 'restore' ? 'fix' : 'ask'}] ${f.summary}\n → ${f.action}`);
|
|
639
|
+
const accept = async (f) => {
|
|
640
|
+
if (yes) return true;
|
|
641
|
+
if (!isInteractive()) return false;
|
|
642
|
+
return confirm(`\nApply: ${f.summary}`);
|
|
643
|
+
};
|
|
644
|
+
const r = await repair({ target, dryRun: dry, invoked: true, accept });
|
|
645
|
+
say('');
|
|
646
|
+
if (dry) return void say(`Dry run — nothing written. Would apply ${r.applied.length}.`);
|
|
647
|
+
say(`Repaired ${r.applied.length} thing(s).${r.backup ? ' A copy of the previous state was kept.' : ''}`);
|
|
648
|
+
for (const p of r.pending) say(` still pending (your call): ${p.summary}\n → ${p.action}`);
|
|
649
|
+
return void say(' ↻ Reload/restart your assistant.');
|
|
650
|
+
}
|
|
580
651
|
case 'purge':
|
|
581
652
|
return void (await runPurge(argv.includes('--dry-run'), argv.includes('--with-docs')));
|
|
582
653
|
default:
|
|
583
654
|
say(`rsc: unknown command '${cmd}'.`);
|
|
584
|
-
say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | sello <on|off|status|…> | backups | restore <id|latest> | upgrade | uninstall <id> | purge');
|
|
655
|
+
say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | sello <on|off|status|…> | backups | restore <id|latest> | upgrade | repair | uninstall <id> | purge');
|
|
656
|
+
say('Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)');
|
|
657
|
+
say(' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.');
|
|
585
658
|
process.exitCode = 1;
|
|
586
659
|
}
|
|
587
660
|
}
|
package/scripts/spec-gate.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
8
8
|
import { join, dirname, basename } from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
|
-
import {
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import { specCompleteness, statusClaims, checkClaims } from './lib/spec-gate.js';
|
|
11
12
|
|
|
12
13
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
14
|
const DEFAULT_DIR = join(ROOT, '02-DOCS', 'wiki', 'sdd', 'specs');
|
|
@@ -22,6 +23,35 @@ function defaultTargets() {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
// The repository, asked instead of assumed. Returns null when there is nothing to ask — a
|
|
27
|
+
// report-only instrument that crashes where it cannot look is worse than one that says so.
|
|
28
|
+
function gitProbe(root) {
|
|
29
|
+
const git = (args) => execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
30
|
+
let main;
|
|
31
|
+
try {
|
|
32
|
+
git(['rev-parse', '--git-dir']);
|
|
33
|
+
// Prefer the remote tip — a stale local `main` reports merged work as missing. But CI checks out
|
|
34
|
+
// detached with no `origin/main` and no local `main`, and falling through to "no repository to
|
|
35
|
+
// ask" there means the check silently stops checking exactly where it runs unattended. HEAD is
|
|
36
|
+
// the honest last resort: on CI it IS the branch under test.
|
|
37
|
+
main = ['origin/main', 'main', 'HEAD'].find((ref) => {
|
|
38
|
+
try { git(['rev-parse', '--verify', ref]); return true; } catch { return false; }
|
|
39
|
+
});
|
|
40
|
+
if (!main) throw new Error('no ref to compare against');
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
hasCommit: (sha) => { try { git(['cat-file', '-e', `${sha}^{commit}`]); return true; } catch { return false; } },
|
|
46
|
+
isAncestor: (sha) => { try { git(['merge-base', '--is-ancestor', sha, main]); return true; } catch { return false; } },
|
|
47
|
+
// The squash-merge subject carries `(#N)`; --fixed-strings so the parens are not a regex.
|
|
48
|
+
hasPr: (n) => { try { return git(['log', main, '--grep', `(#${n})`, '--fixed-strings', '-1', '--format=%H']) !== ''; } catch { return false; } },
|
|
49
|
+
hasTag: (v) => { try { return git(['tag', '-l', `v${v}`]) !== ''; } catch { return false; } },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const statusOf = (text) => (/^status:[ \t]*(.*)$/m.exec(text) || [, ''])[1];
|
|
54
|
+
|
|
25
55
|
function main() {
|
|
26
56
|
const args = process.argv.slice(2);
|
|
27
57
|
const targets = args.length ? args : defaultTargets();
|
|
@@ -31,8 +61,21 @@ function main() {
|
|
|
31
61
|
}
|
|
32
62
|
|
|
33
63
|
let failed = 0;
|
|
64
|
+
let drifted = 0;
|
|
65
|
+
// One probe per repository, not one for the tool's own. A spec's claims are about the history of
|
|
66
|
+
// the project the spec belongs to — checking them against wherever this script happens to live
|
|
67
|
+
// gives the right answer only by coincidence, and the wrong one the moment the gate is pointed at
|
|
68
|
+
// another checkout.
|
|
69
|
+
const probes = new Map();
|
|
70
|
+
const probeFor = (path) => {
|
|
71
|
+
const dir = dirname(path);
|
|
72
|
+
if (!probes.has(dir)) probes.set(dir, gitProbe(dir));
|
|
73
|
+
return probes.get(dir);
|
|
74
|
+
};
|
|
75
|
+
let anyProbe = false;
|
|
34
76
|
for (const path of targets) {
|
|
35
|
-
const
|
|
77
|
+
const text = readFileSync(path, 'utf8');
|
|
78
|
+
const r = specCompleteness(text);
|
|
36
79
|
const name = basename(path);
|
|
37
80
|
if (r.ok) {
|
|
38
81
|
const typed = r.openPoints.length - r.untyped.length;
|
|
@@ -40,17 +83,27 @@ function main() {
|
|
|
40
83
|
if (r.untyped.length) {
|
|
41
84
|
console.log(` untyped (treated as open questions): ${r.untyped.length}`);
|
|
42
85
|
}
|
|
43
|
-
}
|
|
86
|
+
}
|
|
87
|
+
if (!r.ok) {
|
|
44
88
|
failed += 1;
|
|
45
89
|
console.log(`FAIL ${name}`);
|
|
46
90
|
if (r.missing.length) console.log(` missing section(s): ${r.missing.join(', ')}`);
|
|
47
91
|
if (r.empty.length) console.log(` section(s) with no content: ${r.empty.join(', ')}`);
|
|
48
92
|
}
|
|
93
|
+
// A status that still matches the repository prints nothing: the report grows only where there
|
|
94
|
+
// is something to act on (P5).
|
|
95
|
+
const probe = probeFor(path);
|
|
96
|
+
anyProbe = anyProbe || Boolean(probe);
|
|
97
|
+
for (const v of checkClaims(statusClaims(statusOf(text)), probe)) {
|
|
98
|
+
if (v.verdict === 'holds') continue;
|
|
99
|
+
if (v.verdict === 'stale') drifted += 1;
|
|
100
|
+
console.log(` status ${v.verdict.toUpperCase()}: ${v.raw} — ${v.reason}`);
|
|
101
|
+
}
|
|
49
102
|
}
|
|
50
103
|
|
|
51
104
|
// A green here is narrower than the rule it enforces, so it says so every run rather than
|
|
52
105
|
// letting the reader assume the gate covered more than it did.
|
|
53
|
-
console.log(`\n${targets.length - failed}/${targets.length} pass. Not checked by this gate:`);
|
|
106
|
+
console.log(`\n${targets.length - failed}/${targets.length} pass, ${drifted} status claim(s) contradicted by the repo${anyProbe ? '' : ' (no repository to ask)'}. Not checked by this gate:`);
|
|
54
107
|
for (const u of specCompleteness('').unchecked) console.log(` - ${u}`);
|
|
55
108
|
if (failed) process.exit(1);
|
|
56
109
|
}
|
package/skills/suggest/SKILL.md
CHANGED
|
@@ -79,7 +79,31 @@ Not covered → one line, skill or agent. Rules: `skill-scout`.
|
|
|
79
79
|
|
|
80
80
|
---
|
|
81
81
|
|
|
82
|
-
## 3.
|
|
82
|
+
## 3. A harness that is broken fixes itself
|
|
83
|
+
|
|
84
|
+
You are injected into every session, so you are the only thing that can notice a broken
|
|
85
|
+
harness before its owner does — and its owner usually cannot, because the symptoms name
|
|
86
|
+
nothing they recognise. When any of these is true, act on it **once** in the session:
|
|
87
|
+
|
|
88
|
+
- the assistant sees no skills in a project that clearly has a harness;
|
|
89
|
+
- `.rsc.json` exists and what it declares is not what is installed;
|
|
90
|
+
- the same hook seems to run several times;
|
|
91
|
+
- the harness is wired for an assistant that is not the one running.
|
|
92
|
+
|
|
93
|
+
Run `npx @ericrisco/rsc doctor`, and say in one line what is wrong **as a symptom**, not as
|
|
94
|
+
a cause. Then:
|
|
95
|
+
|
|
96
|
+
- Anything that only puts the harness back to what was already declared — dangling links, a
|
|
97
|
+
repeated hook, a layout no version uses — say you are fixing it and run
|
|
98
|
+
`npx @ericrisco/rsc repair`. Restoring is not deciding, and a recoverable copy is kept.
|
|
99
|
+
- Anything that would change a decision — moving to another assistant, adopting a skill a
|
|
100
|
+
teammate added, disarming a gate — **ask first**. A `git pull` never rewrites someone's
|
|
101
|
+
machine.
|
|
102
|
+
|
|
103
|
+
Offer once. A "no" holds for the session; a new session may look again, because the problem
|
|
104
|
+
has not gone away. Never mention it when the harness is healthy.
|
|
105
|
+
|
|
106
|
+
## 4. First contact
|
|
83
107
|
|
|
84
108
|
Before handling the first request of a session, check the workspace:
|
|
85
109
|
|
package/targets/_md-block.js
CHANGED
|
@@ -9,6 +9,9 @@ import { linkOrCopy } from './index.js';
|
|
|
9
9
|
// multiple assistants sharing one file never duplicate it.
|
|
10
10
|
const MARK_START = '<!-- rsc-suggest:start -->';
|
|
11
11
|
const MARK_END = '<!-- rsc-suggest:end -->';
|
|
12
|
+
// The exact separator wireHook writes before an appended block, so unwireHook can take
|
|
13
|
+
// back precisely what it gave and leave the rest of the file untouched.
|
|
14
|
+
const SEAM = '\n\n';
|
|
12
15
|
|
|
13
16
|
export function writeSkill(id, fromDir, toPath) {
|
|
14
17
|
return linkOrCopy(fromDir, toPath);
|
|
@@ -21,7 +24,9 @@ export function wireHook(paths, sourceMd) {
|
|
|
21
24
|
if (doc.includes(MARK_START)) {
|
|
22
25
|
doc = doc.replace(new RegExp(`${MARK_START}[\\s\\S]*?${MARK_END}`), block);
|
|
23
26
|
} else {
|
|
24
|
-
|
|
27
|
+
// Append with a fixed seam, and record nothing else. unwireHook removes exactly this
|
|
28
|
+
// seam and this block, which is what lets the file come back byte-identical.
|
|
29
|
+
doc += `${SEAM}${block}\n`;
|
|
25
30
|
}
|
|
26
31
|
mkdirSync(dirname(paths.hookTarget), { recursive: true });
|
|
27
32
|
writeFileSync(paths.hookTarget, doc);
|
|
@@ -34,9 +39,17 @@ export function unwireHook(paths) {
|
|
|
34
39
|
if (!existsSync(paths.hookTarget)) return [];
|
|
35
40
|
const doc = readFileSync(paths.hookTarget, 'utf8');
|
|
36
41
|
if (!doc.includes(MARK_START)) return [];
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
// Remove the block and the seam that introduced it — nothing more.
|
|
43
|
+
//
|
|
44
|
+
// This used to end with a document-wide `\n{3,}` -> `\n\n` pass, which tidied blank
|
|
45
|
+
// lines the author had put hundreds of lines away from anything of ours. In #249 that
|
|
46
|
+
// file was the project's hand-written constitution: it had already received 95 lines it
|
|
47
|
+
// never asked for, and giving it back reformatted is the second half of the same damage.
|
|
48
|
+
// A slightly larger gap is cosmetic; rewriting someone's document is not.
|
|
49
|
+
const cleaned = doc.replace(
|
|
50
|
+
new RegExp(`${SEAM.replace(/\n/g, '\\n')}?${MARK_START}[\\s\\S]*?${MARK_END}\\n?`),
|
|
51
|
+
'',
|
|
52
|
+
);
|
|
40
53
|
writeFileSync(paths.hookTarget, cleaned);
|
|
41
54
|
return [paths.hookTarget];
|
|
42
55
|
}
|
package/targets/claude.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, rmSync } from 'node:fs';
|
|
2
|
-
import { dirname, join } from 'node:path';
|
|
2
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { linkOrCopy } from './index.js';
|
|
5
5
|
|
|
@@ -70,6 +70,15 @@ export function unwireHook(paths) {
|
|
|
70
70
|
// entry (the old `cat …/suggest/SKILL.md` form, or a previous `.sh`/`.mjs` script
|
|
71
71
|
// form) is dropped before we add the current one — idempotent, migrating legacy and
|
|
72
72
|
// bash-era hooks in place. Other (non-rsc) SessionStart hooks are preserved.
|
|
73
|
+
// Commands name the project through ${CLAUDE_PROJECT_DIR}, never through the absolute
|
|
74
|
+
// path of the folder it happened to be installed in. Baking that path in is what made a
|
|
75
|
+
// harness unshareable: a committed settings.json was broken in every other checkout, and
|
|
76
|
+
// even renaming your own folder broke it. The variable is resolved by the client at run
|
|
77
|
+
// time, so the same bytes work on every machine — which is the precondition for the
|
|
78
|
+
// manifest being worth committing at all.
|
|
79
|
+
const P = '${CLAUDE_PROJECT_DIR}';
|
|
80
|
+
const at = (...seg) => `${P}/${seg.join('/')}`;
|
|
81
|
+
|
|
73
82
|
export function wireHook(paths) {
|
|
74
83
|
const scriptDest = join(paths.projectRoot, '.rsc', 'session-start.mjs');
|
|
75
84
|
mkdirSync(dirname(scriptDest), { recursive: true });
|
|
@@ -79,7 +88,8 @@ export function wireHook(paths) {
|
|
|
79
88
|
copyFileSync(join(HERE, 'session-start.mjs'), scriptDest);
|
|
80
89
|
|
|
81
90
|
const suggestMd = `${paths.skillDir('suggest')}/SKILL.md`;
|
|
82
|
-
const
|
|
91
|
+
const suggestRel = at(relative(paths.projectRoot, paths.skillDir('suggest')).split(sep).join('/'), 'SKILL.md');
|
|
92
|
+
const cmd = `node "${at('.rsc', 'session-start.mjs')}" "${suggestRel}" "${P}"`;
|
|
83
93
|
|
|
84
94
|
const file = paths.hookTarget;
|
|
85
95
|
const settings = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
|
|
@@ -99,7 +109,7 @@ export function wireHook(paths) {
|
|
|
99
109
|
// any prior rsc worklog-checkpoint entry (.sh or .mjs) dropped first.
|
|
100
110
|
const wlDest = join(paths.projectRoot, '.rsc', 'worklog-checkpoint.mjs');
|
|
101
111
|
copyFileSync(join(HERE, 'worklog-checkpoint.mjs'), wlDest);
|
|
102
|
-
const wlCmd = `node "${
|
|
112
|
+
const wlCmd = `node "${at('.rsc', 'worklog-checkpoint.mjs')}" "${P}"`;
|
|
103
113
|
for (const event of ['PreCompact', 'SessionEnd']) {
|
|
104
114
|
settings.hooks[event] ||= [];
|
|
105
115
|
settings.hooks[event] = settings.hooks[event].filter(
|
|
@@ -118,7 +128,7 @@ export function wireHook(paths) {
|
|
|
118
128
|
// sello.mjs is ship-guard's sibling import (hooks are materialized file-by-file,
|
|
119
129
|
// so the deterministic sello core must land next to the guard that loads it).
|
|
120
130
|
copyFileSync(join(HERE, 'sello.mjs'), join(paths.projectRoot, '.rsc', 'sello.mjs'));
|
|
121
|
-
const sgCmd = `node "${
|
|
131
|
+
const sgCmd = `node "${at('.rsc', 'ship-guard.mjs')}" "${P}"`;
|
|
122
132
|
settings.hooks.PreToolUse ||= [];
|
|
123
133
|
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
124
134
|
(e) => !hookWiringOf(e).includes('.rsc/ship-guard.'),
|
|
@@ -132,7 +142,7 @@ export function wireHook(paths) {
|
|
|
132
142
|
// node-run (Windows-safe), idempotent, fail-open, opt-out via .rsc/.no-danger-guard.
|
|
133
143
|
const dgDest = join(paths.projectRoot, '.rsc', 'danger-guard.mjs');
|
|
134
144
|
copyFileSync(join(HERE, 'danger-guard.mjs'), dgDest);
|
|
135
|
-
const dgCmd = `node "${
|
|
145
|
+
const dgCmd = `node "${at('.rsc', 'danger-guard.mjs')}" "${P}"`;
|
|
136
146
|
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
137
147
|
(e) => !hookWiringOf(e).includes('.rsc/danger-guard.'),
|
|
138
148
|
);
|
|
@@ -147,7 +157,7 @@ export function wireHook(paths) {
|
|
|
147
157
|
// .rsc/.no-gitmoji.
|
|
148
158
|
const gmDest = join(paths.projectRoot, '.rsc', 'gitmoji-guard.mjs');
|
|
149
159
|
copyFileSync(join(HERE, 'gitmoji-guard.mjs'), gmDest);
|
|
150
|
-
const gmCmd = `node "${
|
|
160
|
+
const gmCmd = `node "${at('.rsc', 'gitmoji-guard.mjs')}" "${P}"`;
|
|
151
161
|
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
152
162
|
(e) => !hookWiringOf(e).includes('.rsc/gitmoji-guard.'),
|
|
153
163
|
);
|
|
@@ -163,7 +173,7 @@ export function wireHook(paths) {
|
|
|
163
173
|
// (non-rsc) UserPromptSubmit hooks are preserved.
|
|
164
174
|
const fgDest = join(paths.projectRoot, '.rsc', 'userprompt-gate.mjs');
|
|
165
175
|
copyFileSync(join(HERE, 'userprompt-gate.mjs'), fgDest);
|
|
166
|
-
const fgCmd = `node "${
|
|
176
|
+
const fgCmd = `node "${at('.rsc', 'userprompt-gate.mjs')}" "${P}"`;
|
|
167
177
|
settings.hooks.UserPromptSubmit ||= [];
|
|
168
178
|
settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
|
|
169
179
|
(e) => !hookWiringOf(e).includes('.rsc/userprompt-gate.'),
|
package/targets/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync, cpSync } from 'node:fs';
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync, cpSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join, dirname, relative } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import * as claudeAdapter from './claude.js';
|
|
5
5
|
import * as cursorAdapter from './cursor.js';
|
|
6
6
|
import * as mdAdapter from './_md-block.js';
|
|
7
|
+
import { readManifest } from '../scripts/lib/manifest-file.js';
|
|
7
8
|
|
|
8
9
|
// Project-local single source of truth. Real skill files live here exactly once;
|
|
9
10
|
// every assistant gets a lightweight pointer (symlink) back to it — no duplication.
|
|
@@ -83,28 +84,74 @@ export const TARGETS = [
|
|
|
83
84
|
{ id: 'aider', label: 'Aider', hint: 'CONVENTIONS.md' },
|
|
84
85
|
];
|
|
85
86
|
|
|
86
|
-
//
|
|
87
|
-
//
|
|
87
|
+
// Which assistants are actually installed here, read from the state file each one
|
|
88
|
+
// writes. This is evidence: only an install of ours writes it. Everything below
|
|
89
|
+
// (detectTarget) is inference from files a human — or our own `harness` skill —
|
|
90
|
+
// may have written for unrelated reasons. Order follows SPEC so the ambiguity
|
|
91
|
+
// message is deterministic. A missing, empty or unreadable state means "not
|
|
92
|
+
// installed", never an error: a half-written file must not break resolution.
|
|
93
|
+
export function installedTargets(cwd = process.cwd()) {
|
|
94
|
+
return TARGET_IDS.filter((id) => {
|
|
95
|
+
try {
|
|
96
|
+
const state = JSON.parse(readFileSync(targetPaths(id, undefined, cwd).stateFile, 'utf8'));
|
|
97
|
+
return Object.keys(state.skills || {}).length > 0;
|
|
98
|
+
} catch { return false; }
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Inference, used only when there is no evidence. Unique config dirs win.
|
|
103
|
+
//
|
|
104
|
+
// Two rules here are scar tissue from issue #249. Claude Code had NO signal at
|
|
105
|
+
// all — it was merely the final fallthrough — so any repo with a root AGENTS.md
|
|
106
|
+
// resolved to codex even with .claude/ full of skills. And AGENTS.md is the
|
|
107
|
+
// weakest signal on purpose: our own `harness` skill writes one into every repo
|
|
108
|
+
// it equips, so treating it as a strong hint made the harness poison its own
|
|
109
|
+
// detection the moment it ran once.
|
|
88
110
|
export function detectTarget(cwd = process.cwd()) {
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
91
|
-
if (
|
|
92
|
-
if (
|
|
93
|
-
if (
|
|
94
|
-
if (
|
|
95
|
-
if (
|
|
96
|
-
if (
|
|
97
|
-
if (
|
|
98
|
-
if (
|
|
99
|
-
if (
|
|
100
|
-
if (
|
|
101
|
-
if (
|
|
102
|
-
if (
|
|
103
|
-
if (
|
|
104
|
-
if (
|
|
111
|
+
const has = (p) => existsSync(join(cwd, p));
|
|
112
|
+
if (has('.cursor')) return 'cursor';
|
|
113
|
+
if (has('.windsurf')) return 'windsurf';
|
|
114
|
+
if (has('.clinerules')) return 'cline';
|
|
115
|
+
if (has('.roo')) return 'roo';
|
|
116
|
+
if (has('.continue')) return 'continue';
|
|
117
|
+
if (has('.junie')) return 'junie';
|
|
118
|
+
if (has('.kiro')) return 'kiro';
|
|
119
|
+
if (has('.zed')) return 'zed';
|
|
120
|
+
if (has('.opencode')) return 'opencode';
|
|
121
|
+
if (has('.amp')) return 'amp';
|
|
122
|
+
if (has('.jules')) return 'jules';
|
|
123
|
+
if (has('.antigravity')) return 'antigravity';
|
|
124
|
+
if (has(join('.github', 'copilot-instructions.md'))) return 'copilot';
|
|
125
|
+
if (has('.claude') || has('CLAUDE.md')) return 'claude';
|
|
126
|
+
if (has('.codex')) return 'codex';
|
|
127
|
+
if (has('.gemini') || has('GEMINI.md')) return 'gemini';
|
|
128
|
+
if (has('AGENTS.md')) return 'codex'; // weakest: our own harness writes this
|
|
105
129
|
return 'claude';
|
|
106
130
|
}
|
|
107
131
|
|
|
132
|
+
// The single point of truth for "which assistant are we acting on". Every command
|
|
133
|
+
// goes through here — including doctor, which used to resolve on its own and so
|
|
134
|
+
// could report a different target than the one just installed into.
|
|
135
|
+
//
|
|
136
|
+
// Ambiguity is a distinct outcome, not a value the caller might mistake for an id:
|
|
137
|
+
// returning a sentinel string would let a forgetful caller pass it to targetPaths()
|
|
138
|
+
// and die with `unknown target`. `ids` empty + `ambiguous` set cannot be used by
|
|
139
|
+
// accident.
|
|
140
|
+
export function resolveTargets({ cwd = process.cwd(), flagValue } = {}) {
|
|
141
|
+
if (typeof flagValue === 'string' && flagValue.trim()) {
|
|
142
|
+
const ids = flagValue.split(',').map((s) => s.trim()).filter(Boolean);
|
|
143
|
+
return { ids, ambiguous: null, source: 'flag' };
|
|
144
|
+
}
|
|
145
|
+
// The committed manifest is what the team decided, so it outranks what happens to be
|
|
146
|
+
// on this machine. Two targets here is not ambiguity — it is the team saying "both".
|
|
147
|
+
const declared = readManifest(cwd)?.targets || [];
|
|
148
|
+
if (declared.length) return { ids: declared, ambiguous: null, source: 'manifest' };
|
|
149
|
+
const found = installedTargets(cwd);
|
|
150
|
+
if (found.length === 1) return { ids: found, ambiguous: null, source: 'evidence' };
|
|
151
|
+
if (found.length > 1) return { ids: [], ambiguous: found, source: 'evidence' };
|
|
152
|
+
return { ids: [detectTarget(cwd)], ambiguous: null, source: 'heuristic' };
|
|
153
|
+
}
|
|
154
|
+
|
|
108
155
|
export function targetPaths(target, home = homedir(), cwd = process.cwd()) {
|
|
109
156
|
const s = SPEC[target];
|
|
110
157
|
if (!s) throw new Error(`unknown target ${target}`);
|