@damiangilgonzalez/harness 1.3.12 → 1.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +36 -11
  2. package/manifest.json +10 -2
  3. package/package.json +3 -2
  4. package/scripts/doctor.js +122 -6
  5. package/scripts/install-apply.js +195 -14
  6. package/scripts/lib/backups.js +13 -0
  7. package/scripts/lib/capabilities.js +19 -2
  8. package/scripts/lib/divergence.js +8 -1
  9. package/scripts/lib/onboarding-apply.js +91 -13
  10. package/scripts/lib/onboarding.js +35 -2
  11. package/scripts/lib/repair.js +8 -4
  12. package/scripts/lib/review-findings.js +175 -0
  13. package/scripts/lib/versions.js +31 -0
  14. package/scripts/rsc.js +117 -19
  15. package/skills/animar/SKILL.md +1 -0
  16. package/skills/claude-project-setup/SKILL.md +1 -0
  17. package/skills/code-review/SKILL.md +18 -1
  18. package/skills/diseno-apple/SKILL.md +1 -0
  19. package/skills/diseno-landing/SKILL.md +1 -0
  20. package/skills/domain-modeling/SKILL.md +1 -0
  21. package/skills/flujo-interfaz/SKILL.md +1 -0
  22. package/skills/grill-me/SKILL.md +1 -0
  23. package/skills/grill-with-docs/SKILL.md +1 -0
  24. package/skills/grilling/SKILL.md +1 -0
  25. package/skills/handoff/SKILL.md +1 -0
  26. package/skills/harness/SKILL.md +6 -0
  27. package/skills/harness/references/wiki-protocol.md +5 -0
  28. package/skills/implement/SKILL.md +1 -0
  29. package/skills/ingeniero-diseno-web/SKILL.md +1 -0
  30. package/skills/init/references/accompaniment-and-profile.md +1 -1
  31. package/skills/leyes-de-percepcion/SKILL.md +1 -0
  32. package/skills/leyes-de-retencion/SKILL.md +1 -0
  33. package/skills/mejor-accesibilidad/SKILL.md +1 -0
  34. package/skills/mejor-colores/SKILL.md +1 -0
  35. package/skills/mejor-layout/SKILL.md +1 -0
  36. package/skills/mejor-redaccion/SKILL.md +1 -0
  37. package/skills/mejor-tipografia/SKILL.md +1 -0
  38. package/skills/mejor-ui/SKILL.md +1 -0
  39. package/skills/muscle-memory/SKILL.md +1 -0
  40. package/skills/orient/SKILL.md +9 -1
  41. package/skills/orient/references/orientation-contract.md +1 -1
  42. package/skills/prototype/SKILL.md +1 -0
  43. package/skills/research/SKILL.md +1 -0
  44. package/skills/revision-de-cambios/SKILL.md +1 -0
  45. package/skills/revision-interfaz/SKILL.md +1 -0
  46. package/skills/sitios-calidad-premio/SKILL.md +1 -0
  47. package/skills/suggest/SKILL.md +10 -9
  48. package/skills/tastemaker/SKILL.md +1 -0
  49. package/skills/teach/SKILL.md +1 -0
  50. package/skills/to-questionnaire/SKILL.md +1 -0
  51. package/skills/to-spec/SKILL.md +1 -0
  52. package/skills/to-tickets/SKILL.md +1 -0
  53. package/skills/video-a-superprompt/SKILL.md +1 -0
  54. package/skills/vocabulario-animacion/SKILL.md +1 -0
  55. package/skills/wait-what/SKILL.md +1 -0
  56. package/skills/wayfinder/SKILL.md +1 -0
  57. package/skills/write-adr/SKILL.md +1 -0
  58. package/skills/writing-for-agents/SKILL.md +1 -0
  59. package/targets/_md-block.js +8 -1
  60. package/targets/agents-md-shadow.js +153 -0
  61. package/targets/agents.js +46 -3
  62. package/targets/claude.js +76 -26
  63. package/targets/clone-bootstrap.mjs +454 -0
  64. package/targets/gitmoji-guard.mjs +30 -3
  65. package/targets/hook-once.mjs +1 -0
  66. package/targets/memory.js +1 -1
  67. package/targets/opt-outs.js +40 -0
  68. package/targets/sello.mjs +2 -2
  69. package/targets/session-memory-adapter.mjs +70 -14
  70. package/targets/session-memory-core.mjs +40 -8
  71. package/targets/session-start.mjs +65 -12
  72. package/targets/ship-guard.mjs +1 -1
  73. package/targets/worktree-reaper.mjs +175 -11
package/README.md CHANGED
@@ -73,6 +73,9 @@ accompaniment, project kind and goal **before it writes anything**, reads only t
73
73
  root, and shows every selected or deferred skill, agent and hook with its reason. Relay those
74
74
  questions to the user; never invent or default their answers. The first pass writes nothing and
75
75
  prints a SHA-256 plan id. Only rerun with `--accept-plan <id>` after the user accepts that exact plan.
76
+ If the project already has a skill of its own with a catalog name, the plan lists it under
77
+ **Your own skills this plan replaces** — tell the user before accepting; renaming their folder and
78
+ regenerating the plan keeps theirs.
76
79
 
77
80
  For a non-interactive agent, collect the answers and preview the same plan explicitly:
78
81
 
@@ -247,7 +250,7 @@ rsc consult "I want to launch a SaaS" # recommend only, no install
247
250
  rsc registry refresh # write .rsc/skill-registry.{json,md}
248
251
  rsc list # installed skills, agents and commands
249
252
  rsc capabilities # installed/available surfaces + memory mode
250
- rsc doctor # health, missing backing, hooks and local memory
253
+ rsc doctor # health, missing backing, hooks and local memory; exits 1 when unhealthy
251
254
  rsc memory status # full / assisted / unsupported / degraded
252
255
  rsc memory save --session handoff # force a deterministic local checkpoint
253
256
  rsc memory resume # print this branch/worktree continuation
@@ -284,23 +287,43 @@ The harness travels by git, but not all of it — and the split is the point.
284
287
  | `docs/raw/worklog/.rsc-memory/` | Preferred session journal when a local wiki exists; protected with git's local exclude |
285
288
  | The skill entries rsc manages | Symlinks on macOS/Linux, real copies on Windows — two incompatible shapes of one thing |
286
289
 
287
- Whoever clones runs **one command** and ends up with the same harness:
290
+ Three files carry the harness through git: `.rsc.json` (what the team decided),
291
+ `.claude/settings.json` (the wiring), and `.claude/rsc-bootstrap.mjs` — the small file that
292
+ notices, in a clone, that the rest is not there yet. **Commit all three.** If your project
293
+ ignores the assistant's directory wholesale, rsc adds the lines that keep those files versioned —
294
+ verified against git, not against the pattern.
295
+
296
+ **And say it plainly, because committing it is the point:** `.claude/rsc-bootstrap.mjs` is code that
297
+ runs on every session and every shell call, so a pull request that edits *that file* runs on the
298
+ machine of whoever reviews the branch. That is true of `.claude/settings.json` already, and of any
299
+ hook-based harness — but it is worth knowing before you agree to commit a third one. Review changes
300
+ to it the way you would review a CI workflow.
301
+
302
+ Whoever clones runs **one command** and ends up with the same harness — at the version the
303
+ project pinned, which is the `catalogVersion` in its `.rsc.json`:
288
304
 
289
305
  ```bash
290
- npx @damiangilgonzalez/harness@latest sync
306
+ npx @damiangilgonzalez/harness@<catalogVersion> sync
291
307
  ```
292
308
 
293
- Same skills, same version — `.rsc.json` pins the catalog, so a teammate who clones in three
294
- months gets what you had, not what shipped since. Upgrading is a deliberate act, never a side
295
- effect of rebuilding.
309
+ **You do not have to know that, or find it.** Open the project and the assistant tells you, in
310
+ the first message: what is missing, what would be installed, and the exact command, naming the
311
+ pinned version. It asks; it does not install anything on its own and it does not hold up
312
+ whatever you sat down to do. Say no and it stops asking on that machine.
313
+
314
+ Not `@latest`, and the difference is the whole point of sharing: a teammate who clones in three
315
+ months gets what you had, not what shipped since. Upgrading is something a person decides, writes
316
+ into `.rsc.json`, and commits — and then it reaches everyone through git, like any other change.
296
317
 
297
318
  When someone changes the harness and you `git pull`, `rsc doctor` tells you what no longer
298
319
  matches. **Nothing is ever written to your machine by a pull** — you are told, and you decide.
299
320
 
300
321
  **Own skills.** A skill your team wrote lives in the repo and already works for whoever clones,
301
- with no command at all. Declare it in `.rsc.json` under `ownSkills` and `doctor` will also say
302
- when someone is missing it — that is all declaring does. rsc never installs, updates or
303
- overwrites it: its version is the commit.
322
+ with no command at all. rsc never installs, updates or overwrites it: its version is the commit.
323
+ Installing a catalog skill of the same name is refused rather than silently winning, and `doctor`
324
+ lists what is yours by reading the files themselves — no list to keep in `.rsc.json`, because a
325
+ list somebody has to remember to update is a list that goes quietly stale. Declaring a skill under
326
+ `ownSkills` still works and still reports when someone is missing it.
304
327
 
305
328
 
306
329
  ## 🩹 Something's off? One command
@@ -312,10 +335,12 @@ Recognise any of these? They are all the same fix.
312
335
  | `"target": "codex"` when you work in Claude Code | |
313
336
  | `This target has no hook injection` and you did not expect that | |
314
337
  | Skills appear that you never asked for | |
315
- | You cloned a repo and your assistant sees no skills at all | |
316
338
  | A hook seems to run several times per turn | |
317
339
  | Template lines showed up inside your hand-written `AGENTS.md` | |
318
340
 
341
+ (A *fresh* clone is not in this table any more: nothing is broken there, the harness was simply
342
+ never built on that machine, and the assistant now says so itself — see **sharing by git** above.)
343
+
319
344
  ```bash
320
345
  npx @damiangilgonzalez/harness@latest repair
321
346
  ```
@@ -660,7 +685,7 @@ The richer surfaces are intentionally narrower than skill support:
660
685
  | Windsurf, Cline, Roo | unsupported | yes | unsupported |
661
686
  | Antigravity, Zed, Continue, Amp, Jules, Aider | unsupported | unsupported | unsupported |
662
687
 
663
- `manifest.json` is the generated public inventory: 33 agents (4 base + 29
688
+ `manifest.json` is the generated public inventory: 34 agents (5 base + 29
664
689
  selective specialists) and 43 command entries (10 fixed + 33 stack aliases).
665
690
  Unsupported means rsc writes nothing for that surface; it does not emulate a
666
691
  provider feature with an unverified file.
package/manifest.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "1.3.12",
2
+ "version": "1.3.14",
3
3
  "counts": {
4
4
  "skills": 291,
5
- "agents": 33,
5
+ "agents": 34,
6
6
  "commands": 43,
7
7
  "fixedCommands": 10
8
8
  },
@@ -5988,6 +5988,14 @@
5988
5988
  "fastapi"
5989
5989
  ]
5990
5990
  },
5991
+ {
5992
+ "id": "finding-verifier",
5993
+ "description": "Adversarial verifier of review findings: takes ONE consolidated finding and tries to prove it false against the code. Default stance: the finding is false; only a reproduced or traced failure is CONFIRMED. Fresh context, edits nothing.",
5994
+ "role": "refuter",
5995
+ "tier": "balanced",
5996
+ "tools": null,
5997
+ "skills": []
5998
+ },
5991
5999
  {
5992
6000
  "id": "flutter-build-resolver",
5993
6001
  "description": "Flutter build resolver: reproduces a failing build and applies the minimum verified repair. NOT widget review or package modernization.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@damiangilgonzalez/harness",
3
- "version": "1.3.12",
3
+ "version": "1.3.14",
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 { existsSync, readFileSync, statSync } from 'node:fs';
2
- import { join } from 'node:path';
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).
@@ -52,6 +65,10 @@ function selloStatus(root) {
52
65
  //
53
66
  // Hookless targets get an empty list by construction: their always-on surface is a
54
67
  // markdown block with no script behind it, so there is nothing that can go missing.
68
+ // A quoted span counts as a path only if it starts like one and holds no shell operator: a whole
69
+ // `sh -c '…'` snippet is not a path, and reading it as one reports a present script missing.
70
+ const SCRIPT_RE =
71
+ /"((?:[A-Za-z]:)?[\\/~][^"&;|]*[\\/]\.rsc[\\/][^"\s]+?\.mjs)"|'((?:[A-Za-z]:)?[\\/~][^'&;|]*[\\/]\.rsc[\\/][^'\s]+?\.mjs)'|([^"'\s]*[\\/]\.rsc[\\/][^"'\s]+\.mjs)/g;
55
72
  export function missingHookScripts({ target, home = homedir(), cwd = process.cwd() } = {}) {
56
73
  if (HOOKLESS_TARGETS.has(target)) return [];
57
74
  const file = targetPaths(target, home, cwd).hookTarget;
@@ -71,14 +88,85 @@ export function missingHookScripts({ target, home = homedir(), cwd = process.cwd
71
88
  const expand = (c) => c.split('${CLAUDE_PROJECT_DIR}').join(cwd);
72
89
  for (const raw of commands) {
73
90
  const cmd = expand(raw);
74
- for (const m of cmd.matchAll(/["']?([^"'\s]*[\\/]\.rsc[\\/][^"'\s]+\.mjs)["']?/g)) {
75
- const script = m[1];
91
+ // Quoted forms first: a project path may hold a space, and stopping at whitespace kept only
92
+ // the tail (`entreno/.rsc/x.mjs`), which then resolved against the cwd and read as missing (#283).
93
+ for (const m of cmd.matchAll(SCRIPT_RE)) {
94
+ const script = m[1] ?? m[2] ?? m[3];
76
95
  if (!existsSync(script)) seen.add(script);
77
96
  }
78
97
  }
79
98
  return [...seen];
80
99
  }
81
100
 
101
+ // Is the post-merge trigger actually armed in this clone? Three states, because they need three
102
+ // different answers: armed, absent (repair it), or someone else's (leave it — chaining is our job
103
+ // at install time, and overwriting a husky hook to fix a convenience would be indefensible).
104
+ function worktreeCleanupStatus(root) {
105
+ if (!existsSync(join(root, '.git'))) return { state: 'not-a-repo' };
106
+ if (existsSync(join(root, '.rsc', '.no-worktree-cleanup'))) return { state: 'off-by-choice' };
107
+ // Ask git where hooks live instead of assuming `.git/hooks`. Until 2.0.1 this read the assumed
108
+ // path, so on a repo with its own `core.hooksPath` it found our inert file there and answered
109
+ // `armed` about a hook git would never run — the check confirming the exact failure it exists to
110
+ // catch. `unreachable` is its own state on purpose: `repair` re-runs the same install, so
111
+ // recommending it here would send the reader round the same loop (P6 wants a way out, not a ring).
112
+ const dir = hooksDir(root);
113
+ if (!hooksDirIsOurs(root, dir)) {
114
+ return {
115
+ state: 'unreachable',
116
+ 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.`,
117
+ };
118
+ }
119
+ const hook = join(dir, 'post-merge');
120
+ if (!existsSync(hook)) {
121
+ return { state: 'absent', action: 'Run `npx @damiangilgonzalez/harness repair` to arm the post-merge cleanup in this clone.' };
122
+ }
123
+ try {
124
+ const armed = readFileSync(hook, 'utf8').includes('rsc-managed worktree cleanup');
125
+ return armed
126
+ ? { state: 'armed' }
127
+ : { state: 'foreign', action: 'A post-merge hook from another tool is in place; `npx @damiangilgonzalez/harness repair` chains ours behind it.' };
128
+ } catch { return { state: 'unreadable' }; }
129
+ }
130
+
131
+ // Every skill this catalog ships declares its origin in its own frontmatter: `risco` for the skills
132
+ // inherited from the original catalog, `damiangil` for the ones this fork added. So "is this ours?"
133
+ // is answerable from the file itself, and does not need the list in
134
+ // `.rsc.json` that nobody ever filled: that list is parallel accounting (P3), it has been empty in
135
+ // every project since it was introduced, and `doctor` has been faithfully reporting on the emptiness.
136
+ //
137
+ // Failing towards "the user's" is deliberate. Reading a catalog skill as the user's leaves a file
138
+ // that stops being updated; reading the user's as the catalog's invites something to overwrite it.
139
+ // Only one of those loses work.
140
+ const CATALOG_ORIGINS = new Set(['risco', 'damiangil']);
141
+
142
+ function ownSkillsIn(paths, declared = []) {
143
+ const present = [];
144
+ let dir;
145
+ try { dir = dirname(paths.skillDir('_')); } catch { return { present, missing: [], declared }; }
146
+ let entries = [];
147
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { /* nothing installed here yet */ }
148
+ for (const entry of entries) {
149
+ // Symlinks included, deliberately. rsc installs a catalog skill AS a symlink into `.rsc/skills/`
150
+ // and a hand-written one is a real directory, so the entry type is itself a provenance signal —
151
+ // and skipping symlinks would have made the frontmatter check below dead code that still looked
152
+ // like it worked. It did: a mutant that removed the check passed every test until this line was
153
+ // fixed. Both signals are read now, and the frontmatter is the one that decides.
154
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
155
+ const md = join(dir, entry.name, 'SKILL.md');
156
+ if (!existsSync(md)) continue;
157
+ let origin = null;
158
+ try {
159
+ const head = readFileSync(md, 'utf8').slice(0, 2048);
160
+ origin = (/^origin:\s*(\S+)\s*$/m.exec(head) || [])[1] || null;
161
+ } catch { continue; }
162
+ if (!CATALOG_ORIGINS.has(origin)) present.push(entry.name);
163
+ }
164
+ // Declared-but-absent stays meaningful for anyone who did fill the list by hand, and it is the
165
+ // only half a derivation cannot cover: a file that is gone cannot describe itself.
166
+ const missing = declared.filter((name) => !existsSync(paths.skillDir(name)));
167
+ return { present: present.sort(), missing, declared };
168
+ }
169
+
82
170
  export function doctor({ target, home, cwd }) {
83
171
  const root = cwd || process.cwd();
84
172
  const paths = targetPaths(target, home, cwd);
@@ -120,7 +208,7 @@ export function doctor({ target, home, cwd }) {
120
208
  const missingAgents = (state.agents || []).filter((id) => {
121
209
  const path = agentPath(target, root, id);
122
210
  return path && !existsSync(path);
123
- }).map((id) => ({ id, action: 'Run `npx @damiangilgonzalez/harness sync` to restore this managed agent.' }));
211
+ }).map((id) => ({ id, path: agentPath(target, root, id), action: 'Run `npx @damiangilgonzalez/harness sync` to restore this managed agent.' }));
124
212
  const report = {
125
213
  target,
126
214
  installed: Object.keys(state.skills),
@@ -134,6 +222,11 @@ export function doctor({ target, home, cwd }) {
134
222
  latest: backups[0]?.id || null,
135
223
  },
136
224
  contextBudget: contextBudget({ target, home, cwd }),
225
+ // The worktree cleanup's trigger is a git hook, and `.git/hooks/` is not cloned. So a repo can
226
+ // carry a perfectly healthy harness and still never clean up, on every machine but the one that
227
+ // ran the installer. Reported, never nagged about: `repair` is what puts it back (P6).
228
+ worktreeCleanup: worktreeCleanupStatus(root),
229
+ ownSkills: ownSkillsIn(paths, readProjectManifest(root)?.ownSkills || []),
137
230
  sello: selloStatus(root),
138
231
  // Whether this harness has a design identity at all. `design` has always DECLARED that it
139
232
  // stops without one; until lib/design-identity.js nothing checked it (P2). Reported here,
@@ -148,6 +241,14 @@ export function doctor({ target, home, cwd }) {
148
241
  gitmojiGuard: state.policy?.gitmojiGuard === false
149
242
  ? 'deferred'
150
243
  : existsSync(join(root, '.rsc', '.no-gitmoji')) ? 'opted-out' : 'armed',
244
+ // The reverse of the same honesty, one level up: a gate the TEAM disarmed in the committed
245
+ // manifest and that is still armed on this machine. Nothing is applied from here — a pull
246
+ // never rewrites somebody's machine — so the only thing owed is saying it out loud, and
247
+ // saying it every run, because a notice offered once per session is a notice missed.
248
+ optOutsNotApplied: optOutsNotApplied(root),
249
+ // First thing to read on any bug report: a project behind its CLI runs old hooks, and "it is
250
+ // already fixed" only helps once the fix is in `.rsc/`.
251
+ versions: versionReport(root),
151
252
  // Counted, never interpreted — by spec, the gap log's reader is the user.
152
253
  automationGaps: countGaps(root),
153
254
  memory,
@@ -157,9 +258,12 @@ export function doctor({ target, home, cwd }) {
157
258
  commandOrphans,
158
259
  commandCollisions: state.commandCollisions || [],
159
260
  };
261
+ // #277 — the three "missing" lists share one shape, { id, path, action }, so whoever builds on top
262
+ // of doctor reads them the same way; and `healthy` is what the exit code says.
160
263
  for (const [id, e] of Object.entries(state.skills)) {
161
- for (const f of e.files) if (!existsSync(f)) report.missing.push(`${id}:${f}`);
264
+ for (const f of e.files) if (!existsSync(f)) report.missing.push({ id, path: f, action: 'Run `npx @ericrisco/rsc sync` to restore this managed skill.' });
162
265
  }
266
+ report.healthy = report.hookWired && !report.missing.length && !missingAgents.length && !missingCommands.length;
163
267
  return report;
164
268
  }
165
269
 
@@ -383,6 +487,18 @@ export function contextBudget({ target, home = homedir(), cwd = process.cwd() }
383
487
  });
384
488
  }
385
489
  const drift = divergence({ cwd, target, home });
490
+ // A separate finding, not a fourth clause of the one below: that one says this checkout
491
+ // is behind its own team, and the fix is to align with the declaration. This one says the
492
+ // declaration itself is behind the catalog, so aligning with it changes nothing. Folded
493
+ // together, the report would name the right skill and the wrong reason.
494
+ if (drift.floorMissing?.length) {
495
+ findings.push({
496
+ id: 'floor-missing',
497
+ severity: 'high',
498
+ 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.`,
499
+ action: 'Run `npx @damiangilgonzalez/harness sync` — it adds what the catalog requires and touches nothing else.',
500
+ });
501
+ }
386
502
  if (drift.missing.length || drift.extra.length || drift.ownMissing.length) {
387
503
  // The day-two case: a teammate changed the harness and this checkout has not caught
388
504
  // up. Reported always, even after someone declines to align — the divergence does not