@damiangilgonzalez/harness 1.3.12 → 1.3.13
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 +31 -9
- package/manifest.json +1 -1
- package/package.json +3 -2
- package/scripts/doctor.js +109 -2
- package/scripts/install-apply.js +127 -7
- package/scripts/lib/capabilities.js +19 -2
- package/scripts/lib/divergence.js +8 -1
- package/scripts/lib/onboarding-apply.js +18 -1
- package/scripts/lib/onboarding.js +27 -2
- package/scripts/lib/versions.js +31 -0
- package/scripts/rsc.js +66 -16
- package/skills/animar/SKILL.md +1 -0
- package/skills/claude-project-setup/SKILL.md +1 -0
- package/skills/code-review/SKILL.md +1 -0
- package/skills/diseno-apple/SKILL.md +1 -0
- package/skills/diseno-landing/SKILL.md +1 -0
- package/skills/domain-modeling/SKILL.md +1 -0
- package/skills/flujo-interfaz/SKILL.md +1 -0
- package/skills/grill-me/SKILL.md +1 -0
- package/skills/grill-with-docs/SKILL.md +1 -0
- package/skills/grilling/SKILL.md +1 -0
- package/skills/handoff/SKILL.md +1 -0
- package/skills/implement/SKILL.md +1 -0
- package/skills/ingeniero-diseno-web/SKILL.md +1 -0
- package/skills/leyes-de-percepcion/SKILL.md +1 -0
- package/skills/leyes-de-retencion/SKILL.md +1 -0
- package/skills/mejor-accesibilidad/SKILL.md +1 -0
- package/skills/mejor-colores/SKILL.md +1 -0
- package/skills/mejor-layout/SKILL.md +1 -0
- package/skills/mejor-redaccion/SKILL.md +1 -0
- package/skills/mejor-tipografia/SKILL.md +1 -0
- package/skills/mejor-ui/SKILL.md +1 -0
- package/skills/muscle-memory/SKILL.md +1 -0
- package/skills/prototype/SKILL.md +1 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/revision-de-cambios/SKILL.md +1 -0
- package/skills/revision-interfaz/SKILL.md +1 -0
- package/skills/sitios-calidad-premio/SKILL.md +1 -0
- package/skills/suggest/SKILL.md +10 -9
- package/skills/tastemaker/SKILL.md +1 -0
- package/skills/teach/SKILL.md +1 -0
- package/skills/to-questionnaire/SKILL.md +1 -0
- package/skills/to-spec/SKILL.md +1 -0
- package/skills/to-tickets/SKILL.md +1 -0
- package/skills/video-a-superprompt/SKILL.md +1 -0
- package/skills/vocabulario-animacion/SKILL.md +1 -0
- package/skills/wait-what/SKILL.md +1 -0
- package/skills/wayfinder/SKILL.md +1 -0
- package/skills/write-adr/SKILL.md +1 -0
- package/skills/writing-for-agents/SKILL.md +1 -0
- package/targets/_md-block.js +8 -1
- package/targets/agents-md-shadow.js +153 -0
- package/targets/agents.js +9 -2
- package/targets/claude.js +52 -11
- package/targets/clone-bootstrap.mjs +454 -0
- package/targets/gitmoji-guard.mjs +30 -3
- package/targets/opt-outs.js +40 -0
- package/targets/session-memory-adapter.mjs +70 -14
- package/targets/session-memory-core.mjs +39 -7
- package/targets/session-start.mjs +63 -10
- package/targets/worktree-reaper.mjs +174 -10
package/README.md
CHANGED
|
@@ -284,23 +284,43 @@ The harness travels by git, but not all of it — and the split is the point.
|
|
|
284
284
|
| `docs/raw/worklog/.rsc-memory/` | Preferred session journal when a local wiki exists; protected with git's local exclude |
|
|
285
285
|
| The skill entries rsc manages | Symlinks on macOS/Linux, real copies on Windows — two incompatible shapes of one thing |
|
|
286
286
|
|
|
287
|
-
|
|
287
|
+
Three files carry the harness through git: `.rsc.json` (what the team decided),
|
|
288
|
+
`.claude/settings.json` (the wiring), and `.claude/rsc-bootstrap.mjs` — the small file that
|
|
289
|
+
notices, in a clone, that the rest is not there yet. **Commit all three.** If your project
|
|
290
|
+
ignores the assistant's directory wholesale, rsc adds the lines that keep those files versioned —
|
|
291
|
+
verified against git, not against the pattern.
|
|
292
|
+
|
|
293
|
+
**And say it plainly, because committing it is the point:** `.claude/rsc-bootstrap.mjs` is code that
|
|
294
|
+
runs on every session and every shell call, so a pull request that edits *that file* runs on the
|
|
295
|
+
machine of whoever reviews the branch. That is true of `.claude/settings.json` already, and of any
|
|
296
|
+
hook-based harness — but it is worth knowing before you agree to commit a third one. Review changes
|
|
297
|
+
to it the way you would review a CI workflow.
|
|
298
|
+
|
|
299
|
+
Whoever clones runs **one command** and ends up with the same harness — at the version the
|
|
300
|
+
project pinned, which is the `catalogVersion` in its `.rsc.json`:
|
|
288
301
|
|
|
289
302
|
```bash
|
|
290
|
-
npx @damiangilgonzalez/harness
|
|
303
|
+
npx @damiangilgonzalez/harness@<catalogVersion> sync
|
|
291
304
|
```
|
|
292
305
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
306
|
+
**You do not have to know that, or find it.** Open the project and the assistant tells you, in
|
|
307
|
+
the first message: what is missing, what would be installed, and the exact command, naming the
|
|
308
|
+
pinned version. It asks; it does not install anything on its own and it does not hold up
|
|
309
|
+
whatever you sat down to do. Say no and it stops asking on that machine.
|
|
310
|
+
|
|
311
|
+
Not `@latest`, and the difference is the whole point of sharing: a teammate who clones in three
|
|
312
|
+
months gets what you had, not what shipped since. Upgrading is something a person decides, writes
|
|
313
|
+
into `.rsc.json`, and commits — and then it reaches everyone through git, like any other change.
|
|
296
314
|
|
|
297
315
|
When someone changes the harness and you `git pull`, `rsc doctor` tells you what no longer
|
|
298
316
|
matches. **Nothing is ever written to your machine by a pull** — you are told, and you decide.
|
|
299
317
|
|
|
300
318
|
**Own skills.** A skill your team wrote lives in the repo and already works for whoever clones,
|
|
301
|
-
with no command at all.
|
|
302
|
-
|
|
303
|
-
|
|
319
|
+
with no command at all. rsc never installs, updates or overwrites it: its version is the commit.
|
|
320
|
+
Installing a catalog skill of the same name is refused rather than silently winning, and `doctor`
|
|
321
|
+
lists what is yours by reading the files themselves — no list to keep in `.rsc.json`, because a
|
|
322
|
+
list somebody has to remember to update is a list that goes quietly stale. Declaring a skill under
|
|
323
|
+
`ownSkills` still works and still reports when someone is missing it.
|
|
304
324
|
|
|
305
325
|
|
|
306
326
|
## 🩹 Something's off? One command
|
|
@@ -312,10 +332,12 @@ Recognise any of these? They are all the same fix.
|
|
|
312
332
|
| `"target": "codex"` when you work in Claude Code | |
|
|
313
333
|
| `This target has no hook injection` and you did not expect that | |
|
|
314
334
|
| Skills appear that you never asked for | |
|
|
315
|
-
| You cloned a repo and your assistant sees no skills at all | |
|
|
316
335
|
| A hook seems to run several times per turn | |
|
|
317
336
|
| Template lines showed up inside your hand-written `AGENTS.md` | |
|
|
318
337
|
|
|
338
|
+
(A *fresh* clone is not in this table any more: nothing is broken there, the harness was simply
|
|
339
|
+
never built on that machine, and the assistant now says so itself — see **sharing by git** above.)
|
|
340
|
+
|
|
319
341
|
```bash
|
|
320
342
|
npx @damiangilgonzalez/harness@latest repair
|
|
321
343
|
```
|
package/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@damiangilgonzalez/harness",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.13",
|
|
4
4
|
"description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -64,5 +64,6 @@
|
|
|
64
64
|
},
|
|
65
65
|
"publishConfig": {
|
|
66
66
|
"access": "public"
|
|
67
|
-
}
|
|
67
|
+
},
|
|
68
|
+
"rscFixes": []
|
|
68
69
|
}
|
package/scripts/doctor.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { hooksDir, hooksDirIsOurs } from '../targets/worktree-reaper.mjs';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { targetPaths, TARGET_IDS } from '../targets/index.js';
|
|
5
6
|
import { readState } from './lib/state.js';
|
|
6
7
|
import { divergence } from './lib/divergence.js';
|
|
7
8
|
import { loadManifest } from './lib/manifest.js';
|
|
9
|
+
import { readManifest as readProjectManifest } from './lib/manifest-file.js';
|
|
8
10
|
import { listBackups } from './lib/backups.js';
|
|
9
11
|
import { WORKFLOW_GATE_TEXT } from '../targets/hook-once.mjs';
|
|
10
12
|
import { isEnabled, checkSello, readSello, countFindings, readEffectiveConfig, validateRiskConfig } from '../targets/sello.mjs';
|
|
@@ -15,6 +17,17 @@ import { resolveCommands, commandPath, targetHasCommands } from '../targets/comm
|
|
|
15
17
|
import { inspectMemoryWiring } from '../targets/memory.js';
|
|
16
18
|
import { metricsSummary } from '../targets/session-memory-core.mjs';
|
|
17
19
|
import { agentPath } from '../targets/agents.js';
|
|
20
|
+
import { projectOptOuts } from '../targets/opt-outs.js';
|
|
21
|
+
import { versionReport } from './lib/versions.js';
|
|
22
|
+
|
|
23
|
+
// Gates the committed manifest says the team disarmed, still armed here. Read through the same
|
|
24
|
+
// partition the installer uses, so a machine-only switch somebody's older rsc wrote into the
|
|
25
|
+
// manifest (`harness`, `context7`) is not reported as something to converge on.
|
|
26
|
+
function optOutsNotApplied(root) {
|
|
27
|
+
let manifest = null;
|
|
28
|
+
try { manifest = readProjectManifest(root); } catch { return []; }
|
|
29
|
+
return projectOptOuts(manifest?.optOuts).filter((n) => !existsSync(join(root, '.rsc', `.no-${n}`)));
|
|
30
|
+
}
|
|
18
31
|
|
|
19
32
|
// The sello's health, surfaced where the user already looks (spec: non-blocking
|
|
20
33
|
// findings live in the project and are SUMMARIZED here, never nagged about).
|
|
@@ -79,6 +92,75 @@ export function missingHookScripts({ target, home = homedir(), cwd = process.cwd
|
|
|
79
92
|
return [...seen];
|
|
80
93
|
}
|
|
81
94
|
|
|
95
|
+
// Is the post-merge trigger actually armed in this clone? Three states, because they need three
|
|
96
|
+
// different answers: armed, absent (repair it), or someone else's (leave it — chaining is our job
|
|
97
|
+
// at install time, and overwriting a husky hook to fix a convenience would be indefensible).
|
|
98
|
+
function worktreeCleanupStatus(root) {
|
|
99
|
+
if (!existsSync(join(root, '.git'))) return { state: 'not-a-repo' };
|
|
100
|
+
if (existsSync(join(root, '.rsc', '.no-worktree-cleanup'))) return { state: 'off-by-choice' };
|
|
101
|
+
// Ask git where hooks live instead of assuming `.git/hooks`. Until 2.0.1 this read the assumed
|
|
102
|
+
// path, so on a repo with its own `core.hooksPath` it found our inert file there and answered
|
|
103
|
+
// `armed` about a hook git would never run — the check confirming the exact failure it exists to
|
|
104
|
+
// catch. `unreachable` is its own state on purpose: `repair` re-runs the same install, so
|
|
105
|
+
// recommending it here would send the reader round the same loop (P6 wants a way out, not a ring).
|
|
106
|
+
const dir = hooksDir(root);
|
|
107
|
+
if (!hooksDirIsOurs(root, dir)) {
|
|
108
|
+
return {
|
|
109
|
+
state: 'unreachable',
|
|
110
|
+
action: `git reads hooks from ${dir}, which belongs to this repo or to another tool (husky, lefthook, a committed dispatcher). rsc will not write there. To arm the cleanup, add \`node "$(git rev-parse --show-toplevel)/.rsc/worktree-reaper.mjs" "$(git rev-parse --show-toplevel)" auto\` to your own post-merge hook.`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const hook = join(dir, 'post-merge');
|
|
114
|
+
if (!existsSync(hook)) {
|
|
115
|
+
return { state: 'absent', action: 'Run `npx @damiangilgonzalez/harness repair` to arm the post-merge cleanup in this clone.' };
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const armed = readFileSync(hook, 'utf8').includes('rsc-managed worktree cleanup');
|
|
119
|
+
return armed
|
|
120
|
+
? { state: 'armed' }
|
|
121
|
+
: { state: 'foreign', action: 'A post-merge hook from another tool is in place; `npx @damiangilgonzalez/harness repair` chains ours behind it.' };
|
|
122
|
+
} catch { return { state: 'unreadable' }; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Every skill this catalog ships declares its origin in its own frontmatter: `risco` for the skills
|
|
126
|
+
// inherited from the original catalog, `damiangil` for the ones this fork added. So "is this ours?"
|
|
127
|
+
// is answerable from the file itself, and does not need the list in
|
|
128
|
+
// `.rsc.json` that nobody ever filled: that list is parallel accounting (P3), it has been empty in
|
|
129
|
+
// every project since it was introduced, and `doctor` has been faithfully reporting on the emptiness.
|
|
130
|
+
//
|
|
131
|
+
// Failing towards "the user's" is deliberate. Reading a catalog skill as the user's leaves a file
|
|
132
|
+
// that stops being updated; reading the user's as the catalog's invites something to overwrite it.
|
|
133
|
+
// Only one of those loses work.
|
|
134
|
+
const CATALOG_ORIGINS = new Set(['risco', 'damiangil']);
|
|
135
|
+
|
|
136
|
+
function ownSkillsIn(paths, declared = []) {
|
|
137
|
+
const present = [];
|
|
138
|
+
let dir;
|
|
139
|
+
try { dir = dirname(paths.skillDir('_')); } catch { return { present, missing: [], declared }; }
|
|
140
|
+
let entries = [];
|
|
141
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { /* nothing installed here yet */ }
|
|
142
|
+
for (const entry of entries) {
|
|
143
|
+
// Symlinks included, deliberately. rsc installs a catalog skill AS a symlink into `.rsc/skills/`
|
|
144
|
+
// and a hand-written one is a real directory, so the entry type is itself a provenance signal —
|
|
145
|
+
// and skipping symlinks would have made the frontmatter check below dead code that still looked
|
|
146
|
+
// like it worked. It did: a mutant that removed the check passed every test until this line was
|
|
147
|
+
// fixed. Both signals are read now, and the frontmatter is the one that decides.
|
|
148
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
149
|
+
const md = join(dir, entry.name, 'SKILL.md');
|
|
150
|
+
if (!existsSync(md)) continue;
|
|
151
|
+
let origin = null;
|
|
152
|
+
try {
|
|
153
|
+
const head = readFileSync(md, 'utf8').slice(0, 2048);
|
|
154
|
+
origin = (/^origin:\s*(\S+)\s*$/m.exec(head) || [])[1] || null;
|
|
155
|
+
} catch { continue; }
|
|
156
|
+
if (!CATALOG_ORIGINS.has(origin)) present.push(entry.name);
|
|
157
|
+
}
|
|
158
|
+
// Declared-but-absent stays meaningful for anyone who did fill the list by hand, and it is the
|
|
159
|
+
// only half a derivation cannot cover: a file that is gone cannot describe itself.
|
|
160
|
+
const missing = declared.filter((name) => !existsSync(paths.skillDir(name)));
|
|
161
|
+
return { present: present.sort(), missing, declared };
|
|
162
|
+
}
|
|
163
|
+
|
|
82
164
|
export function doctor({ target, home, cwd }) {
|
|
83
165
|
const root = cwd || process.cwd();
|
|
84
166
|
const paths = targetPaths(target, home, cwd);
|
|
@@ -134,6 +216,11 @@ export function doctor({ target, home, cwd }) {
|
|
|
134
216
|
latest: backups[0]?.id || null,
|
|
135
217
|
},
|
|
136
218
|
contextBudget: contextBudget({ target, home, cwd }),
|
|
219
|
+
// The worktree cleanup's trigger is a git hook, and `.git/hooks/` is not cloned. So a repo can
|
|
220
|
+
// carry a perfectly healthy harness and still never clean up, on every machine but the one that
|
|
221
|
+
// ran the installer. Reported, never nagged about: `repair` is what puts it back (P6).
|
|
222
|
+
worktreeCleanup: worktreeCleanupStatus(root),
|
|
223
|
+
ownSkills: ownSkillsIn(paths, readProjectManifest(root)?.ownSkills || []),
|
|
137
224
|
sello: selloStatus(root),
|
|
138
225
|
// Whether this harness has a design identity at all. `design` has always DECLARED that it
|
|
139
226
|
// stops without one; until lib/design-identity.js nothing checked it (P2). Reported here,
|
|
@@ -148,6 +235,14 @@ export function doctor({ target, home, cwd }) {
|
|
|
148
235
|
gitmojiGuard: state.policy?.gitmojiGuard === false
|
|
149
236
|
? 'deferred'
|
|
150
237
|
: existsSync(join(root, '.rsc', '.no-gitmoji')) ? 'opted-out' : 'armed',
|
|
238
|
+
// The reverse of the same honesty, one level up: a gate the TEAM disarmed in the committed
|
|
239
|
+
// manifest and that is still armed on this machine. Nothing is applied from here — a pull
|
|
240
|
+
// never rewrites somebody's machine — so the only thing owed is saying it out loud, and
|
|
241
|
+
// saying it every run, because a notice offered once per session is a notice missed.
|
|
242
|
+
optOutsNotApplied: optOutsNotApplied(root),
|
|
243
|
+
// First thing to read on any bug report: a project behind its CLI runs old hooks, and "it is
|
|
244
|
+
// already fixed" only helps once the fix is in `.rsc/`.
|
|
245
|
+
versions: versionReport(root),
|
|
151
246
|
// Counted, never interpreted — by spec, the gap log's reader is the user.
|
|
152
247
|
automationGaps: countGaps(root),
|
|
153
248
|
memory,
|
|
@@ -383,6 +478,18 @@ export function contextBudget({ target, home = homedir(), cwd = process.cwd() }
|
|
|
383
478
|
});
|
|
384
479
|
}
|
|
385
480
|
const drift = divergence({ cwd, target, home });
|
|
481
|
+
// A separate finding, not a fourth clause of the one below: that one says this checkout
|
|
482
|
+
// is behind its own team, and the fix is to align with the declaration. This one says the
|
|
483
|
+
// declaration itself is behind the catalog, so aligning with it changes nothing. Folded
|
|
484
|
+
// together, the report would name the right skill and the wrong reason.
|
|
485
|
+
if (drift.floorMissing?.length) {
|
|
486
|
+
findings.push({
|
|
487
|
+
id: 'floor-missing',
|
|
488
|
+
severity: 'high',
|
|
489
|
+
summary: `.rsc.json predates a skill the catalog now requires: ${drift.floorMissing.join(', ')}. The always-on body routes work to it, so that route currently answers nowhere.`,
|
|
490
|
+
action: 'Run `npx @damiangilgonzalez/harness sync` — it adds what the catalog requires and touches nothing else.',
|
|
491
|
+
});
|
|
492
|
+
}
|
|
386
493
|
if (drift.missing.length || drift.extra.length || drift.ownMissing.length) {
|
|
387
494
|
// The day-two case: a teammate changed the harness and this checkout has not caught
|
|
388
495
|
// up. Reported always, even after someone declines to align — the divergence does not
|
package/scripts/install-apply.js
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
import { rmSync, existsSync, cpSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, appendFileSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { join, dirname, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
5
6
|
import { planInstall } from './install-plan.js';
|
|
6
7
|
import { targetPaths, writeSkill, wireHook, unwireHook, baseDir, TARGET_IDS } from '../targets/index.js';
|
|
8
|
+
import { shadowTarget } from '../targets/agents-md-shadow.js';
|
|
7
9
|
import {
|
|
8
10
|
targetHasAgents, reconcileAgents, agentPath, agentNames,
|
|
9
|
-
resolveAgentNames, agentByName, allAgentNames, readDeveloperTier,
|
|
11
|
+
resolveAgentNames, agentByName, allAgentNames, readDeveloperTier, writeDeveloperTier,
|
|
10
12
|
} from '../targets/agents.js';
|
|
13
|
+
import { projectOptOuts } from '../targets/opt-outs.js';
|
|
11
14
|
import { readState, writeState } from './lib/state.js';
|
|
15
|
+
import { withDefaultSkillFloor } from './lib/default-skill-floor.js';
|
|
12
16
|
import { readManifest, writeManifest } from './lib/manifest-file.js';
|
|
13
17
|
import { createBackup } from './lib/backups.js';
|
|
14
18
|
import {
|
|
@@ -66,6 +70,9 @@ function ensureBase(id, cwd, baseVersions) {
|
|
|
66
70
|
export function generatedHookFiles({ target, cwd, policy }) {
|
|
67
71
|
if (target !== 'claude') return [];
|
|
68
72
|
const lifecycle = [
|
|
73
|
+
// Committed, not machine state — and therefore the one entry here that, if omitted, leaves an
|
|
74
|
+
// executable behind in a tree the user pushes.
|
|
75
|
+
join(cwd, '.claude', 'rsc-bootstrap.mjs'),
|
|
69
76
|
join(cwd, '.rsc', 'session-start.mjs'),
|
|
70
77
|
join(cwd, '.rsc', 'worklog-checkpoint.mjs'),
|
|
71
78
|
join(cwd, '.rsc', 'hook-once.mjs'),
|
|
@@ -109,6 +116,12 @@ export function managedPathsForInstall({ skillIds, agentIds = [], target, home,
|
|
|
109
116
|
out.push(step.to, ...generatedHookFiles({ target, cwd, policy }));
|
|
110
117
|
}
|
|
111
118
|
}
|
|
119
|
+
// The shadow CLAUDE.md that keeps a natively-read AGENTS.md from doubling the always-on body.
|
|
120
|
+
// Written by whichever of the two adapters is wired second, so it is declared for both families,
|
|
121
|
+
// not just for claude. A claude install must assume its own wiring: settings.json does not name
|
|
122
|
+
// it yet at plan time.
|
|
123
|
+
const shadow = shadowTarget(cwd, { assumeClaudeWired: target === 'claude' });
|
|
124
|
+
if (shadow) out.push(shadow);
|
|
112
125
|
return [...new Set(out)];
|
|
113
126
|
}
|
|
114
127
|
|
|
@@ -116,17 +129,56 @@ export function managedPathsForInstall({ skillIds, agentIds = [], target, home,
|
|
|
116
129
|
// which is machine-local and therefore lost on every clone: a team that disarmed the
|
|
117
130
|
// gitmoji guard found it armed again on the next checkout, with nobody having decided
|
|
118
131
|
// that. They are decisions, so they belong in the committed declaration.
|
|
132
|
+
//
|
|
133
|
+
// Two distinctions this function exists to keep, both of which used to be collapsed:
|
|
134
|
+
//
|
|
135
|
+
// ABSENT is not EMPTY. In a clone `.rsc/` does not exist, and reading "no markers here" as
|
|
136
|
+
// "the team re-armed everything" would have sync quietly delete the decision it came to
|
|
137
|
+
// rebuild. Unreadable returns null — "I know nothing" — and the caller keeps what was
|
|
138
|
+
// declared. A readable directory with no markers returns [], which IS a statement: somebody
|
|
139
|
+
// deleted the last marker, and deleting it is how a gate gets re-armed.
|
|
140
|
+
//
|
|
141
|
+
// Not every switch is the team's to make. `.no-harness` and friends are decisions of one
|
|
142
|
+
// machine (see targets/opt-outs.js), and committing them would impose one laptop's answer on
|
|
143
|
+
// every future clone. They stay local, and never reach the declaration.
|
|
119
144
|
function localDecisions(cwd) {
|
|
120
145
|
const dir = join(cwd, '.rsc');
|
|
121
|
-
let optOuts =
|
|
146
|
+
let optOuts = null;
|
|
122
147
|
try {
|
|
123
|
-
optOuts = readdirSync(dir).filter((f) => f.startsWith('.no-')).map((f) => f.slice(4))
|
|
124
|
-
} catch { /* no .rsc yet */ }
|
|
148
|
+
optOuts = projectOptOuts(readdirSync(dir).filter((f) => f.startsWith('.no-')).map((f) => f.slice(4)));
|
|
149
|
+
} catch { /* no .rsc yet → null, meaning "unknown", never "none" */ }
|
|
125
150
|
let tier = null;
|
|
126
151
|
try { tier = JSON.parse(readFileSync(join(dir, 'developer.json'), 'utf8')).tier ?? null; } catch { /* unset */ }
|
|
127
152
|
return { optOuts, tier };
|
|
128
153
|
}
|
|
129
154
|
|
|
155
|
+
// The other half of the same promise: what the declaration says is rebuilt as the real local
|
|
156
|
+
// state a hook can see. Hooks are materialized standalone under `.rsc/` and decide with one
|
|
157
|
+
// `existsSync` — they cannot read the manifest, and making them read it would let a `git pull`
|
|
158
|
+
// change how somebody's session behaves in silence, which `project-manifest` ruled out on
|
|
159
|
+
// purpose. So the manifest is applied HERE, inside the install the person chose to run.
|
|
160
|
+
//
|
|
161
|
+
// Only ever on a machine where `.rsc/` did not exist: there, "no marker" carries no information
|
|
162
|
+
// and the declaration is all there is. Where `.rsc/` is already built, the local state is the
|
|
163
|
+
// authority — otherwise the next sync would silently undo a re-arm, and the gate coming back
|
|
164
|
+
// would look like a bug in the guard rather than a decision nobody made.
|
|
165
|
+
export function hydrateLocalDecisions(cwd, manifest) {
|
|
166
|
+
const applied = { optOuts: [], tier: null };
|
|
167
|
+
if (!manifest) return applied;
|
|
168
|
+
const dir = join(cwd, '.rsc');
|
|
169
|
+
for (const name of projectOptOuts(manifest.optOuts)) {
|
|
170
|
+
const file = join(dir, `.no-${name}`);
|
|
171
|
+
if (existsSync(file)) continue;
|
|
172
|
+
mkdirSync(dir, { recursive: true });
|
|
173
|
+
writeFileSync(file, `Declared in .rsc.json by this project; rebuilt here by rsc ${CLI_VERSION}.\n`);
|
|
174
|
+
applied.optOuts.push(name);
|
|
175
|
+
}
|
|
176
|
+
if (manifest.tier && !existsSync(join(dir, 'developer.json'))) {
|
|
177
|
+
applied.tier = writeDeveloperTier(cwd, manifest.tier);
|
|
178
|
+
}
|
|
179
|
+
return applied;
|
|
180
|
+
}
|
|
181
|
+
|
|
130
182
|
// Record the decision, merging so a second assistant never erases the first: installing
|
|
131
183
|
// into codex on a machine that already had claude is adding, not replacing. Union, sorted
|
|
132
184
|
// where order carries no meaning, so the file stays diffable and a merge conflict stays
|
|
@@ -146,17 +198,25 @@ export function recordInManifest({ cwd, target, skillIds, agentIds = [], catalog
|
|
|
146
198
|
ownSkills: prev.ownSkills || [],
|
|
147
199
|
catalogVersion,
|
|
148
200
|
tier: tier ?? prev.tier ?? null,
|
|
149
|
-
optOuts: optOuts
|
|
201
|
+
optOuts: optOuts ?? projectOptOuts(prev.optOuts),
|
|
150
202
|
memory: prev.memory,
|
|
151
203
|
onboarding: onboarding ?? prev.onboarding,
|
|
152
204
|
});
|
|
153
205
|
}
|
|
154
206
|
|
|
155
207
|
export async function applyInstall({ skillIds = [], agentIds = [], target, home, cwd = process.cwd(), operation = 'install', dryRun = false, policy, onboarding }) {
|
|
208
|
+
// Read BEFORE anything can create it — the backup writes under `.rsc/`, and one line later the
|
|
209
|
+
// answer would always be "yes, it exists". This single boolean is what separates a clone (the
|
|
210
|
+
// declaration is the only thing that knows what the team decided) from a built machine (the
|
|
211
|
+
// local markers are the authority, and a pull must not overwrite them).
|
|
212
|
+
const fromScratch = !existsSync(join(cwd, '.rsc'));
|
|
156
213
|
const paths = targetPaths(target, home, cwd);
|
|
157
214
|
const plan = planInstall({ skillIds, target, home, cwd, hooks: policy?.alwaysOn !== false });
|
|
158
215
|
const managedPaths = managedPathsForInstall({ skillIds, agentIds, target, home, cwd, policy });
|
|
159
216
|
if (dryRun) return { dryRun: true, skills: skillIds, agents: agentIds, paths: managedPaths };
|
|
217
|
+
// Rebuild the declared decisions first, so everything below (the agent tier especially) reads
|
|
218
|
+
// the state the team declared rather than the default it would otherwise assume.
|
|
219
|
+
if (fromScratch) hydrateLocalDecisions(cwd, readManifest(cwd));
|
|
160
220
|
const state = readState(paths.stateFile);
|
|
161
221
|
const backup = createBackup({ cwd, operation, target, paths: managedPaths, cliVersion: CLI_VERSION });
|
|
162
222
|
// Decide base refresh per skill (see baseVersionsFile): a base is re-materialized when
|
|
@@ -237,8 +297,30 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
|
|
|
237
297
|
mkdirSync(dirname(versionFile(cwd)), { recursive: true });
|
|
238
298
|
writeFileSync(versionFile(cwd), CLI_VERSION + '\n');
|
|
239
299
|
recordInManifest({ cwd, target, skillIds, agentIds, onboarding });
|
|
300
|
+
// The worktree cleanup's trigger. It lives in `.git/hooks/`, which is NOT cloned, so it has to be
|
|
301
|
+
// (re)written by every operation that touches a user's repo — install, sync and repair all land
|
|
302
|
+
// here. Target-agnostic on purpose: it is a git hook, not an assistant hook, and a cleanup that
|
|
303
|
+
// only worked for one of the sixteen assistants would be a cleanup nobody could rely on. The
|
|
304
|
+
// reaper travels with it for the same reason; until now only the Claude adapter materialized it,
|
|
305
|
+
// although `managedPathsForInstall` has always declared it for every target.
|
|
306
|
+
// Only where the accepted plan governs code hooks. An operations harness — company, research,
|
|
307
|
+
// content — declares a narrower set of paths, and writing a git hook it never agreed to would be
|
|
308
|
+
// acting outside the plan the user accepted (P4). Caught by the onboarding route-inventory gate,
|
|
309
|
+
// which is exactly the kind of catch that justifies its existence.
|
|
310
|
+
// The answer used to be discarded, so a hook that could not arm looked exactly like one that
|
|
311
|
+
// did — and the only place that could have noticed, `doctor`, was reading the same wrong path.
|
|
312
|
+
// Reported 2026-09-18. Carried out, never thrown: the harness installing is the point, and the
|
|
313
|
+
// merge hook is a convenience layered on top of it.
|
|
314
|
+
let mergeHook = { installed: false, state: 'skipped' };
|
|
315
|
+
try {
|
|
316
|
+
if (policy?.codeHooks === false) throw new Error('code hooks not governed by the accepted plan');
|
|
317
|
+
mkdirSync(join(cwd, '.rsc'), { recursive: true });
|
|
318
|
+
cpSync(join(ROOT, 'targets', 'worktree-reaper.mjs'), join(cwd, '.rsc', 'worktree-reaper.mjs'));
|
|
319
|
+
const { installMergeHook } = await import('../targets/worktree-reaper.mjs');
|
|
320
|
+
if (existsSync(join(cwd, '.git'))) mergeHook = installMergeHook(cwd);
|
|
321
|
+
} catch (err) { mergeHook = { installed: false, state: 'skipped', reason: err.message }; }
|
|
240
322
|
ignoreLocalState(cwd, target);
|
|
241
|
-
return { ...state, backup };
|
|
323
|
+
return { ...state, backup, mergeHook };
|
|
242
324
|
}
|
|
243
325
|
|
|
244
326
|
// `.rsc/` holds local machine state — hook scripts, install markers, the sello's
|
|
@@ -287,6 +369,32 @@ export function ignoreLocalState(cwd = process.cwd(), target) {
|
|
|
287
369
|
// keeps this idempotent against a .gitignore a human wrote in their own spelling.
|
|
288
370
|
const norm = (l) => l.trim().replace(/^\//, '').replace(/\/$/, '');
|
|
289
371
|
const present = new Set(text.split('\n').map(norm));
|
|
372
|
+
// `.rsc/` is machine state and stays ignored. The two files below are the opposite: they are the
|
|
373
|
+
// whole reason a clone can help itself, and a project that excluded the assistant's directory
|
|
374
|
+
// wholesale (rsc's own repo does exactly that, so copying the example is likely) swallows both with
|
|
375
|
+
// no symptom at all.
|
|
376
|
+
//
|
|
377
|
+
// Asked of GIT, not of the pattern. The first attempt at this reasoned about spellings and shipped
|
|
378
|
+
// a negation that was INERT in every case it fired: git does not descend into an excluded
|
|
379
|
+
// directory, so `!.claude/rsc-bootstrap.mjs` under `.claude/` rescues nothing. Measured, not
|
|
380
|
+
// argued — and the lesson is the one this repo keeps relearning, that a rule believed and untrue is
|
|
381
|
+
// worse than no rule.
|
|
382
|
+
const RESCUE = ['.claude/rsc-bootstrap.mjs', '.claude/settings.json'];
|
|
383
|
+
const swallowed = RESCUE.filter((f) => {
|
|
384
|
+
try {
|
|
385
|
+
return spawnSync('git', ['check-ignore', '-q', '--', f], { cwd }).status === 0;
|
|
386
|
+
} catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
if (swallowed.length) {
|
|
391
|
+
// Append-only, and this exact order is what makes it work at all: re-include the directory so git
|
|
392
|
+
// will descend into it, exclude everything inside it again, then rescue by name. Verified against
|
|
393
|
+
// git itself — `settings.local.json` and the skill entries stay ignored.
|
|
394
|
+
const dir = swallowed[0].split('/')[0];
|
|
395
|
+
for (const line of [`!${dir}/`, `${dir}/*`, ...swallowed.map((f) => `!${f}`)]) wanted.push(line);
|
|
396
|
+
}
|
|
397
|
+
|
|
290
398
|
const add = wanted.filter((w) => !present.has(norm(w)));
|
|
291
399
|
if (!add.length) return null;
|
|
292
400
|
|
|
@@ -482,7 +590,19 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
|
|
|
482
590
|
const ids = Object.keys(state.skills || {});
|
|
483
591
|
const manifest = readManifest(cwd);
|
|
484
592
|
const governedSkills = manifest?.onboarding?.plan?.policy?.skills;
|
|
485
|
-
|
|
593
|
+
// The accepted plan is a FLOOR, not a ceiling. Reading it as the whole declaration is what deleted
|
|
594
|
+
// users' skills: `add` recorded the new skill in `manifest.skills` — the living list — and sync
|
|
595
|
+
// then rebuilt from a governed list that predated it and pruned the difference, recursively.
|
|
596
|
+
// Reported by a user and reproduced 2026-09-17. The receipt stays untouched, because it is
|
|
597
|
+
// hash-checked against what the user accepted and is not a log of what happened since.
|
|
598
|
+
const declaredRaw = governedSkills
|
|
599
|
+
? [...new Set([...governedSkills, ...(manifest?.skills || [])])].sort()
|
|
600
|
+
: (ids.length ? ids : (manifest?.skills || []));
|
|
601
|
+
// A declaration is frozen at the moment it was written, so a skill the catalog later makes
|
|
602
|
+
// mandatory never reaches an existing harness: `add` and `install` apply the floor, sync did
|
|
603
|
+
// not, and sync is the only one an upgrade runs. Applied only to a harness that already declares
|
|
604
|
+
// something: a directory declaring nothing is not a harness missing its floor.
|
|
605
|
+
const declared = declaredRaw.length ? withDefaultSkillFloor(declaredRaw) : declaredRaw;
|
|
486
606
|
const declaredAgents = state.explicitAgents?.length ? state.explicitAgents : (manifest?.agents || []);
|
|
487
607
|
if (!declared.length && !declaredAgents.length) return dryRun ? { dryRun: true, synced: [], syncedAgents: [], paths: [] } : { synced: [], syncedAgents: [], backup: null };
|
|
488
608
|
if (dryRun) {
|
|
@@ -44,6 +44,12 @@ function agentSpecFor(target, root) {
|
|
|
44
44
|
return { dir: dirname(p), ext };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// A real file, or a link that leads to one. `statSync` follows; a dangling link throws and is
|
|
48
|
+
// therefore not a file, which is the answer we want.
|
|
49
|
+
function isFile(path) {
|
|
50
|
+
try { return statSync(path).isFile(); } catch { return false; }
|
|
51
|
+
}
|
|
52
|
+
|
|
47
53
|
// Real files only. Directories (a dir named `foo.md` is not an agent), dangling
|
|
48
54
|
// symlinks and dotfiles are not capabilities.
|
|
49
55
|
function entriesWithExt(dir, ext) {
|
|
@@ -96,8 +102,19 @@ export function listSkills({ target, home, cwd = process.cwd() } = {}) {
|
|
|
96
102
|
try {
|
|
97
103
|
for (const e of readdirSync(paths.dir, { withFileTypes: true })) {
|
|
98
104
|
if (e.name.startsWith('.')) continue;
|
|
99
|
-
|
|
100
|
-
|
|
105
|
+
// Ask what a skill IS, never how it was put on disk. A `Dirent` from `withFileTypes`
|
|
106
|
+
// describes the LINK and not its target, so `isDirectory()`/`isFile()` are both false for
|
|
107
|
+
// every skill on macOS and Linux — where a symlink per assistant, pointing at one shared
|
|
108
|
+
// copy, is the layout the installer deliberately writes. Windows gets real files (relative
|
|
109
|
+
// dir symlinks need Developer Mode there), which is why this read as correct for so long
|
|
110
|
+
// and why CI never caught it.
|
|
111
|
+
//
|
|
112
|
+
// `existsSync` and `statSync` follow links on their own, so one question answers all three
|
|
113
|
+
// platforms with no branch: is there a SKILL.md behind this name? Following a link is not
|
|
114
|
+
// the same as believing anything — a dangling link, a directory with no SKILL.md and a
|
|
115
|
+
// plain file all still fail it.
|
|
116
|
+
if (ext) { if (e.name.endsWith(ext) && isFile(join(paths.dir, e.name))) out.push({ id: e.name.slice(0, -ext.length), scope }); }
|
|
117
|
+
else if (existsSync(join(paths.dir, e.name, 'SKILL.md'))) out.push({ id: e.name, scope });
|
|
101
118
|
}
|
|
102
119
|
} catch { /* unreadable scope → contributes nothing */ }
|
|
103
120
|
}
|
|
@@ -2,6 +2,7 @@ import { readManifest } from './manifest-file.js';
|
|
|
2
2
|
import { readState } from './state.js';
|
|
3
3
|
import { targetPaths } from '../../targets/index.js';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
|
+
import { DEFAULT_SKILL_FLOOR } from './default-skill-floor.js';
|
|
5
6
|
|
|
6
7
|
// What the team declared versus what is on this machine.
|
|
7
8
|
//
|
|
@@ -15,10 +16,13 @@ import { existsSync } from 'node:fs';
|
|
|
15
16
|
// ownMissing — declared team skill, absent from the repo. Aligning says so and writes
|
|
16
17
|
// nothing: it comes from the repo, not from us.
|
|
17
18
|
// extra — installed BY RSC, no longer declared.
|
|
19
|
+
// floorMissing — a skill the catalog makes mandatory that this declaration predates.
|
|
20
|
+
// Not drift between teammates: drift between the harness and the catalog,
|
|
21
|
+
// and the only bucket that survives everyone running the same `git pull`.
|
|
18
22
|
// A hand-written skill that nobody declared is in none of them. Not declaring is a valid
|
|
19
23
|
// way to keep things of your own, not an oversight to correct.
|
|
20
24
|
export function divergence({ cwd = process.cwd(), target, home } = {}) {
|
|
21
|
-
const empty = { missing: [], extra: [], ownMissing: [] };
|
|
25
|
+
const empty = { missing: [], extra: [], ownMissing: [], floorMissing: [] };
|
|
22
26
|
const manifest = readManifest(cwd);
|
|
23
27
|
if (!manifest) return empty;
|
|
24
28
|
|
|
@@ -29,5 +33,8 @@ export function divergence({ cwd = process.cwd(), target, home } = {}) {
|
|
|
29
33
|
missing: (manifest.skills || []).filter((id) => !installed.has(id)),
|
|
30
34
|
extra: [...installed].filter((id) => !(manifest.skills || []).includes(id)),
|
|
31
35
|
ownMissing: (manifest.ownSkills || []).filter((name) => !existsSync(paths.skillDir(name))),
|
|
36
|
+
// Read off the declaration, not off the disk: a skill installed by hand into a harness
|
|
37
|
+
// that still does not declare it is one sync away from being pruned again.
|
|
38
|
+
floorMissing: DEFAULT_SKILL_FLOOR.filter((id) => !(manifest.skills || []).includes(id)),
|
|
32
39
|
};
|
|
33
40
|
}
|
|
@@ -4,6 +4,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { applyInstall, pruneSharedBases, removeTargetInstall } from '../install-apply.js';
|
|
6
6
|
import { targetPaths } from '../../targets/index.js';
|
|
7
|
+
import { targetHasAgents } from '../../targets/agents.js';
|
|
7
8
|
import { readState } from './state.js';
|
|
8
9
|
import { readManifest, writeManifest } from './manifest-file.js';
|
|
9
10
|
import { encodeGoal, identifyPlan } from './onboarding.js';
|
|
@@ -11,6 +12,22 @@ import { createBackup, restoreBackup } from './backups.js';
|
|
|
11
12
|
|
|
12
13
|
const sameSet = (a = [], b = []) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* What THIS target should be holding, which is not the same question as what the project decided.
|
|
17
|
+
*
|
|
18
|
+
* The policy is the PROJECT's: "these are the agents this project wants." Whether an assistant can
|
|
19
|
+
* hold a subagent at all is the TARGET's business, and nine of the seventeen cannot — amp, jules,
|
|
20
|
+
* zed, antigravity, windsurf, cline, roo, continue and aider have no such concept. The installer
|
|
21
|
+
* always knew this and correctly wrote none; the verifier did not, and demanded the project's list
|
|
22
|
+
* from every target, so onboarding could never complete on any of the nine. Since the failure rolls
|
|
23
|
+
* back, what the person got was empty directory shells and no harness — with a recovery command
|
|
24
|
+
* that leads straight back to the same wall.
|
|
25
|
+
*
|
|
26
|
+
* Asking amp for a subagent is not a divergence. It is a category error, and this is where the two
|
|
27
|
+
* categories stop being confused.
|
|
28
|
+
*/
|
|
29
|
+
export const expectedAgentsFor = (target, plan) => (targetHasAgents(target) ? (plan.policy.agents || []) : []);
|
|
30
|
+
|
|
14
31
|
function inside(root, path) {
|
|
15
32
|
return path === root || path.startsWith(`${root}${sep}`);
|
|
16
33
|
}
|
|
@@ -92,7 +109,7 @@ export function verifyOnboarding(cwd, plan, planId) {
|
|
|
92
109
|
for (const target of plan.policy.targets) {
|
|
93
110
|
const state = readState(targetPaths(target, undefined, cwd).stateFile);
|
|
94
111
|
if (!sameSet(Object.keys(state.skills || {}), plan.policy.skills)) differences.push(`${target}: installed skills differ from accepted policy`);
|
|
95
|
-
if (!sameSet(state.agents || [], plan
|
|
112
|
+
if (!sameSet(state.agents || [], expectedAgentsFor(target, plan))) differences.push(`${target}: installed agents differ from accepted policy`);
|
|
96
113
|
if (state.policy?.alwaysOn !== plan.policy.alwaysOn) differences.push(`${target}: always-on policy differs`);
|
|
97
114
|
if (state.policy?.codeHooks !== plan.policy.codeHooks) differences.push(`${target}: code-hook policy differs`);
|
|
98
115
|
if (state.policy?.memory !== plan.policy.memory) differences.push(`${target}: memory policy differs`);
|
|
@@ -4,6 +4,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
|
4
4
|
import { TARGET_IDS } from '../../targets/index.js';
|
|
5
5
|
import { resolveAgentNames } from '../../targets/agents.js';
|
|
6
6
|
import { loadManifest, skillsForProfile } from './manifest.js';
|
|
7
|
+
import { readManifest } from './manifest-file.js';
|
|
7
8
|
import { managedPathsForInstall } from '../install-apply.js';
|
|
8
9
|
|
|
9
10
|
export const ONBOARDING_SCHEMA_VERSION = 1;
|
|
@@ -68,7 +69,17 @@ export function scanProject(root = process.cwd()) {
|
|
|
68
69
|
let markdownCount = 0;
|
|
69
70
|
let sourceFileCount = 0;
|
|
70
71
|
const visit = (dir) => {
|
|
71
|
-
|
|
72
|
+
// A directory we are not allowed to open is a fact about that directory, not a reason to abandon
|
|
73
|
+
// the install. Reported from a real Windows box: the global harness died walking `Temp\WinSAT`,
|
|
74
|
+
// which Windows protects, on 1.3.2 and again on 1.3.6 — so it had never worked there at all.
|
|
75
|
+
// The safe form already existed in `detect-repo.js` and was simply not reused here.
|
|
76
|
+
let entries;
|
|
77
|
+
try {
|
|
78
|
+
entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
79
|
+
} catch {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
for (const entry of entries) {
|
|
72
83
|
if (ignored.has(entry.name) || entry.isSymbolicLink()) continue;
|
|
73
84
|
const path = join(dir, entry.name);
|
|
74
85
|
const rel = relative(absolute, path).split(sep).join('/');
|
|
@@ -169,7 +180,21 @@ export function buildOnboardingPlan(record, evidence) {
|
|
|
169
180
|
const catalog = loadManifest();
|
|
170
181
|
const catalogIds = new Set(catalog.skills.map((skill) => skill.id));
|
|
171
182
|
const detectedSkills = (evidence.stacks || []).filter((stack) => catalogIds.has(stack));
|
|
172
|
-
|
|
183
|
+
// Skills the user asked for BY NAME are kept across an update; skills a profile handed out are not.
|
|
184
|
+
//
|
|
185
|
+
// Without this, an update recomputed the set from the profile and the detected stacks alone, and
|
|
186
|
+
// the install step then deleted from disk whatever was not in that fresh set — every `rsc add`,
|
|
187
|
+
// on every update. But choosing a narrower scope on purpose still has to be able to REMOVE what the
|
|
188
|
+
// wider one installed, so the difference comes from where the skill came from: a profile skill
|
|
189
|
+
// declares its profiles, and a skill you can only get by naming it declares none. What a profile
|
|
190
|
+
// gave you, a profile may take away; what you asked for by name, only you may. Filtered against the
|
|
191
|
+
// catalog on the way in, because preserving a declaration is not the same as trusting it.
|
|
192
|
+
//
|
|
193
|
+
// `readManifest` returns null on a first install, which is the most common path of all.
|
|
194
|
+
const inAnyProfile = new Set(catalog.skills.filter((s) => (s.profiles || []).length).map((s) => s.id));
|
|
195
|
+
const declared = (readManifest(evidence.root ?? process.cwd())?.skills ?? [])
|
|
196
|
+
.filter((id) => catalogIds.has(id) && !inAnyProfile.has(id));
|
|
197
|
+
const skills = [...new Set([...skillsForProfile(catalog, profile), ...detectedSkills, ...declared])].sort();
|
|
173
198
|
const baseAgents = needsWorkflow;
|
|
174
199
|
const hooks = needsWorkflow;
|
|
175
200
|
const agents = baseAgents ? resolveAgentNames(skills, []).sort() : [];
|