@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
@@ -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
- for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
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,11 +180,32 @@ 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
- const skills = [...new Set([...skillsForProfile(catalog, profile), ...detectedSkills])].sort();
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() : [];
176
201
  const gitmojiGuard = hooks && normalized.targets.includes('claude');
202
+ // The danger guard is NOT code machinery, and tying it to `hooks` was the mistake behind #273. The
203
+ // three guards above need a code project with git — a branch to protect, a commit to lint. This one
204
+ // stops recursive deletes, history-rewriting pushes and friends, which are exactly as irreversible
205
+ // in an invoicing workspace, and `init` promises it by technical level: non-technical and mixed get
206
+ // it, whatever the project is. The guard also decides at runtime from the profile, so the plan only
207
+ // chooses whether it is present, never whether it bites.
208
+ const dangerGuard = normalized.technicalLevel !== 'technical';
177
209
  const decisions = skills.map((id) => detectedSkills.includes(id)
178
210
  ? selected(id, 'skill', `Detected ${id} evidence inside the selected project root.`, 'workspace-evidence')
179
211
  : selected(id, 'skill', needsWorkflow
@@ -223,6 +255,7 @@ export function buildOnboardingPlan(record, evidence) {
223
255
  alwaysOn: true,
224
256
  codeHooks: needsWorkflow,
225
257
  gitmojiGuard,
258
+ dangerGuard,
226
259
  memory: true,
227
260
  context7: false,
228
261
  plugins: needsWorkflow ? ['superpowers@claude-plugins-official'] : [],
@@ -131,22 +131,26 @@ export async function repair({ cwd = process.cwd(), target, home = homedir(), dr
131
131
 
132
132
  const paths = targetPaths(target, home, cwd);
133
133
  const ids = [...new Set([...Object.keys(readState(paths.stateFile).skills || {}), ...(readManifest(cwd)?.skills || [])])];
134
+ // The accepted plan governs a repair exactly as it governs a sync. Without it, `wireHook` saw `{}`,
135
+ // read `codeHooks !== false` as true and wired every guard the plan had declined — and the next
136
+ // `sync`, which does pass the policy, took them away again (#274). Restoring is not deciding.
137
+ const policy = readManifest(cwd)?.onboarding?.plan?.policy;
134
138
  const backup = createBackup({
135
139
  cwd, operation: 'repair', target,
136
- paths: managedPathsForInstall({ skillIds: ids, target, home, cwd }),
140
+ paths: managedPathsForInstall({ skillIds: ids, target, home, cwd, policy }),
137
141
  });
138
142
 
139
143
  for (const f of todo) {
140
144
  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' });
145
+ if (f.id === 'duplicate-hooks') { unwireHook(target, paths); await applyInstall({ skillIds: ids, target, home, cwd, operation: 'repair', policy }); }
146
+ if (f.id === 'dangling-links') await applyInstall({ skillIds: ids, target, home, cwd, operation: 'repair', policy });
143
147
  if (f.id === 'no-manifest') recordInManifest({ cwd, target, skillIds: ids });
144
148
  if (f.id === 'wrong-target') {
145
149
  // Install where the project actually points, THEN take the old wiring down — in that
146
150
  // order, so a failure halfway leaves a working harness rather than none. Taking the
147
151
  // block out of the old file gives it back byte-identical: in #249 that file was the
148
152
  // 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' });
153
+ await applyInstall({ skillIds: ids, target: f.to, home, cwd, operation: 'repair', policy });
150
154
  unwireHook(target, paths);
151
155
  removeManagedSkills(paths);
152
156
  recordInManifest({ cwd, target: f.to, skillIds: ids, dropTarget: target });
@@ -0,0 +1,175 @@
1
+ // Consolidation of the refuter panel's findings — the deterministic half of `review`.
2
+ //
3
+ // Three fresh-context lenses attack the same diff, so they often report the SAME defect in three
4
+ // different sentences, and the author spends triage time discovering that eleven findings are four.
5
+ // Grouping them is an algorithm, not a judgement (P1), so it lives here and not in the skill's prose.
6
+ // What survives grouping and is serious enough to block goes to `finding-verifier` — that half IS
7
+ // judgement, and stays in the agent.
8
+ //
9
+ // Input: what the lenses emit — fenced ```json findings blocks inside their reports, or plain JSON.
10
+ // Output: unique findings, highest severity first, each with the lenses that reported it and a
11
+ // `verify` flag on the ones the verifier must try to refute before they may block.
12
+
13
+ export const SEVERITIES = ['blocker', 'should-fix', 'nit', 'question'];
14
+ const ALIASES = {
15
+ critical: 'blocker', high: 'blocker', blocking: 'blocker',
16
+ medium: 'should-fix', shouldfix: 'should-fix', 'should_fix': 'should-fix', major: 'should-fix',
17
+ low: 'nit', info: 'nit', minor: 'nit',
18
+ };
19
+ const rank = (s) => SEVERITIES.indexOf(s);
20
+ export const DEFAULT_WINDOW = 3;
21
+
22
+ export function normalizeSeverity(raw) {
23
+ const s = String(raw || '').trim().toLowerCase();
24
+ if (SEVERITIES.includes(s)) return s;
25
+ return ALIASES[s] || ALIASES[s.replace(/[-\s]/g, '')] || null;
26
+ }
27
+
28
+ const text = (v) => (typeof v === 'string' ? v.trim() : '');
29
+ const normFile = (f) => text(f).replace(/\\/g, '/').replace(/^\.\//, '');
30
+ const normEvidence = (e) => text(e).replace(/\s+/g, ' ').toLowerCase();
31
+
32
+ // Pull every finding out of one source. A source is a lens report (markdown with fenced blocks) or a
33
+ // JSON file. Anything unparseable is reported, never silently dropped: a lost finding is the one
34
+ // failure a consolidation step must not add.
35
+ export function parseFindings(raw, source = 'stdin') {
36
+ const findings = [];
37
+ const errors = [];
38
+ const take = (value, where) => {
39
+ const list = Array.isArray(value) ? value : (Array.isArray(value?.findings) ? value.findings : [value]);
40
+ for (const item of list) {
41
+ if (!item || typeof item !== 'object') { errors.push(`${where}: a finding must be an object`); continue; }
42
+ findings.push({ ...item, source });
43
+ }
44
+ };
45
+ const blocks = [...String(raw).matchAll(/```json[ \t]+findings[^\n]*\n([\s\S]*?)```/g)];
46
+ if (blocks.length) {
47
+ blocks.forEach((m, i) => {
48
+ try { take(JSON.parse(m[1]), `${source} block ${i + 1}`); }
49
+ catch (e) { errors.push(`${source} block ${i + 1}: not valid JSON (${e.message})`); }
50
+ });
51
+ return { findings, errors };
52
+ }
53
+ const trimmed = String(raw).trim();
54
+ if (!trimmed) return { findings, errors };
55
+ try { take(JSON.parse(trimmed), source); }
56
+ catch { errors.push(`${source}: no \`\`\`json findings block and not plain JSON`); }
57
+ return { findings, errors };
58
+ }
59
+
60
+ // The pre-report gate, checked instead of trusted: a blocker or should-fix with no location or no
61
+ // concrete failure scenario is a suspicion, and a suspicion is a question. The downgrade is visible.
62
+ export function normalizeFinding(f) {
63
+ const out = {
64
+ lens: text(f.lens) || text(f.source) || 'unknown',
65
+ severity: normalizeSeverity(f.severity),
66
+ file: normFile(f.file),
67
+ line: Number.isInteger(Number(f.line)) && Number(f.line) > 0 ? Number(f.line) : null,
68
+ claim: text(f.claim) || text(f.title) || text(f.summary),
69
+ failure_scenario: text(f.failure_scenario) || text(f.repro),
70
+ evidence: text(f.evidence),
71
+ notes: [],
72
+ };
73
+ if (!out.severity) {
74
+ out.notes.push(`severity "${f.severity ?? ''}" unknown → question`);
75
+ out.severity = 'question';
76
+ }
77
+ if (rank(out.severity) <= rank('should-fix')) {
78
+ const missing = [];
79
+ if (!out.file || !out.line) missing.push('file:line');
80
+ if (!out.failure_scenario) missing.push('failure_scenario');
81
+ if (missing.length) {
82
+ out.notes.push(`${out.severity} without ${missing.join(' and ')} → question`);
83
+ out.severity = 'question';
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+
89
+ // Two findings are the same defect when they quote the same evidence, or when they point at the same
90
+ // place (same file, lines within the window) AND say the same thing. Place alone is not enough: the
91
+ // first real panel run put two different defects on adjacent lines (a discount applied to one unit,
92
+ // and a missing 0..100 check), and merging them buried the second one where no verifier looked.
93
+ // A missed merge costs one extra verification; a wrong merge hides a defect — so the bar is
94
+ // conservative. "Say the same thing" is word overlap (Jaccard) of claim, or claim + failure scenario.
95
+ const STOP = new Set('the and for with not that this its are was from any only one but has have into than then when which will can its'.split(' '));
96
+ const words = (s) => new Set(String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP.has(w)));
97
+ function overlap(a, b) {
98
+ const A = words(a); const B = words(b);
99
+ let shared = 0;
100
+ for (const w of A) if (B.has(w)) shared += 1;
101
+ const union = A.size + B.size - shared;
102
+ return union ? shared / union : 0;
103
+ }
104
+ export const SAME_CLAIM = 0.2;
105
+ export function similarity(a, b) {
106
+ return Math.max(overlap(a.claim, b.claim),
107
+ overlap(`${a.claim} ${a.failure_scenario}`, `${b.claim} ${b.failure_scenario}`));
108
+ }
109
+ function sameDefect(a, b, window) {
110
+ const ea = normEvidence(a.evidence);
111
+ if (ea.length >= 12 && ea === normEvidence(b.evidence)) return true;
112
+ const near = a.file && a.file === b.file && a.line && b.line && Math.abs(a.line - b.line) <= window;
113
+ return Boolean(near) && similarity(a, b) >= SAME_CLAIM;
114
+ }
115
+
116
+ export function consolidate(rawFindings, { window = DEFAULT_WINDOW } = {}) {
117
+ const items = rawFindings.map(normalizeFinding);
118
+ const parent = items.map((_, i) => i);
119
+ const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
120
+ for (let i = 0; i < items.length; i += 1) {
121
+ for (let j = i + 1; j < items.length; j += 1) {
122
+ if (sameDefect(items[i], items[j], window)) parent[find(j)] = find(i);
123
+ }
124
+ }
125
+ const groups = new Map();
126
+ items.forEach((it, i) => {
127
+ const root = find(i);
128
+ if (!groups.has(root)) groups.set(root, []);
129
+ groups.get(root).push(it);
130
+ });
131
+ const uniq = (xs) => [...new Set(xs.filter(Boolean))];
132
+ const merged = [...groups.values()].map((members) => {
133
+ const severity = SEVERITIES[Math.min(...members.map((m) => rank(m.severity)))];
134
+ const lines = members.map((m) => m.line).filter(Boolean);
135
+ return {
136
+ severity,
137
+ file: uniq(members.map((m) => m.file))[0] || '',
138
+ lines: lines.length ? [Math.min(...lines), Math.max(...lines)] : [],
139
+ lenses: uniq(members.map((m) => m.lens)).sort(),
140
+ claims: uniq(members.map((m) => m.claim)),
141
+ failure_scenarios: uniq(members.map((m) => m.failure_scenario)),
142
+ evidence: uniq(members.map((m) => m.evidence)),
143
+ notes: uniq(members.flatMap((m) => m.notes)),
144
+ reported: members.length,
145
+ verify: rank(severity) <= rank('should-fix'),
146
+ };
147
+ });
148
+ merged.sort((a, b) => rank(a.severity) - rank(b.severity)
149
+ || a.file.localeCompare(b.file) || (a.lines[0] || 0) - (b.lines[0] || 0));
150
+ merged.forEach((m, i) => { m.id = `F${i + 1}`; });
151
+ return {
152
+ reported: items.length,
153
+ unique: merged.length,
154
+ toVerify: merged.filter((m) => m.verify).length,
155
+ findings: merged,
156
+ };
157
+ }
158
+
159
+ const where = (f) => (f.file ? `${f.file}${f.lines.length ? `:${f.lines[0]}${f.lines[1] !== f.lines[0] ? `-${f.lines[1]}` : ''}` : ''}` : '(no location)');
160
+
161
+ export function renderConsolidation(result, errors = []) {
162
+ const out = [
163
+ `${result.reported} finding(s) reported → ${result.unique} unique · ${result.toVerify} go to finding-verifier`,
164
+ ];
165
+ for (const f of result.findings) {
166
+ out.push('', `${f.id} [${f.severity}] ${where(f)} — ${f.claims[0] || '(no claim)'}`);
167
+ out.push(` lenses: ${f.lenses.join(', ')}${f.reported > 1 ? ` (${f.reported} reports merged)` : ''}`);
168
+ for (const c of f.claims.slice(1)) out.push(` also: ${c}`);
169
+ for (const s of f.failure_scenarios) out.push(` failure: ${s}`);
170
+ for (const n of f.notes) out.push(` note: ${n}`);
171
+ if (f.verify) out.push(' → verify: dispatch finding-verifier; it blocks only if CONFIRMED');
172
+ }
173
+ for (const e of errors) out.push('', `⚠️ ${e}`);
174
+ return out.join('\n');
175
+ }
@@ -0,0 +1,31 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ // Which rsc is running, and which rsc this project was last built with. Two different numbers, and
6
+ // the gap between them is where a published fix fails to arrive: hooks are materialized as copies
7
+ // under `.rsc/`, so a release changes nothing in a project until someone syncs it. Nobody could see
8
+ // that gap before — there was no `--version`, and bug reports arrived without one.
9
+
10
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
11
+ export const CLI_VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
12
+
13
+ /** `.rsc/.version` is written at install and at every sync — the version this project's hooks came from. */
14
+ export function installedVersion(root) {
15
+ try { return readFileSync(join(root, '.rsc', '.version'), 'utf8').trim() || null; } catch { return null; }
16
+ }
17
+
18
+ export function isNewer(a, b) {
19
+ const pa = String(a).split('.').map(Number);
20
+ const pb = String(b).split('.').map(Number);
21
+ for (let i = 0; i < 3; i++) {
22
+ if ((pa[i] || 0) > (pb[i] || 0)) return true;
23
+ if ((pa[i] || 0) < (pb[i] || 0)) return false;
24
+ }
25
+ return false;
26
+ }
27
+
28
+ export function versionReport(root) {
29
+ const installed = installedVersion(root);
30
+ return { cli: CLI_VERSION, installed, behind: Boolean(installed && isNewer(CLI_VERSION, installed)) };
31
+ }
package/scripts/rsc.js CHANGED
@@ -4,18 +4,19 @@ import { detectTarget, installedTargets, resolveTargets, TARGETS } from '../targ
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, listInstalledAgents, listInstalledCommands, uninstall, syncInstalled, purge, collisions } from './install-apply.js';
7
+ import { applyInstall, listInstalled, listInstalledAgents, listInstalledCommands, uninstall, syncInstalled, purge, collisions, ownSkillPaths } from './install-apply.js';
8
8
  import { stackAgentNames } from '../targets/agents.js';
9
9
  import { doctor } from './doctor.js';
10
10
  import { ask, say, select, pickFrom, banner, confirm, isInteractive } from './lib/ui.js';
11
11
  import { refreshRegistry, registryStatus } from './lib/registry.js';
12
12
  import { audit, writeAuditReport } from './audit.js';
13
13
  import { DOMAINS } from './lib/domains.js';
14
- import { listBackups, restoreBackup } from './lib/backups.js';
14
+ import { listBackups, restoreBackup, whereKept } from './lib/backups.js';
15
15
  import { runUpgrade } from './lib/upgrade.js';
16
16
  import { diagnose, repair } from './lib/repair.js';
17
17
  import { DEFAULT_SKILL_FLOOR, withDefaultSkillFloor } from './lib/default-skill-floor.js';
18
18
  import { readManifest, writeManifest } from './lib/manifest-file.js';
19
+ import { versionReport } from './lib/versions.js';
19
20
  import {
20
21
  normalizeOnboarding, missingOnboardingFields, scanProject,
21
22
  buildOnboardingPlan, decodeGoal, encodeGoal, identifyPlan, recommendDeferredComponents,
@@ -23,10 +24,24 @@ import {
23
24
  import { applyAcceptedOnboarding, verifyOnboarding, ensureHarnessSkeleton, harnessReadiness } from './lib/onboarding-apply.js';
24
25
 
25
26
  const rawArgv = process.argv.slice(2);
27
+
28
+ // `rsc --version` — answered before anything else runs, offline, and in a shape a bug report can
29
+ // paste. Two numbers because there are two things: the CLI you just ran, and the harness this
30
+ // project was built with. When they differ the project is running old hooks, and a fix that was
31
+ // published may simply not be here yet.
32
+ if (['--version', '-v', 'version'].includes(rawArgv[0])) {
33
+ const v = versionReport(process.cwd());
34
+ const lines = [`rsc ${v.cli}`];
35
+ if (v.installed) lines.push(`installed in this project: ${v.installed}${v.behind ? ' (behind)' : ''}`);
36
+ else lines.push('installed in this project: none');
37
+ if (v.behind) lines.push('This project runs older hooks than this CLI. To catch up, run here:', ' npx @damiangilgonzalez/harness@latest');
38
+ process.stdout.write(`${lines.join('\n')}\n`);
39
+ process.exit(0);
40
+ }
26
41
  const GLOBAL_VALUE_FLAGS = new Set([
27
42
  '--target', '--technical-level', '--accompaniment', '--project-kind', '--goal', '--goal-base64', '--software-scope', '--accept-plan',
28
43
  ]);
29
- const COMMANDS = new Set(['onboard', 'reassess', 'add', 'install', 'consult', 'catalog', 'audit', 'list', 'doctor', 'memory', 'sync', 'backups', 'restore', 'upgrade', 'registry', 'worktrees', 'capabilities', 'sello', 'repair', 'uninstall', 'purge']);
44
+ const COMMANDS = new Set(['onboard', 'reassess', 'add', 'install', 'consult', 'catalog', 'audit', 'list', 'doctor', 'memory', 'sync', 'backups', 'restore', 'upgrade', 'registry', 'worktrees', 'capabilities', 'review', 'sello', 'repair', 'uninstall', 'purge']);
30
45
  function positionalTokens(input) {
31
46
  const out = [];
32
47
  for (let i = 0; i < input.length; i++) {
@@ -127,6 +142,12 @@ function renderPlan(plan, planId) {
127
142
  for (const decision of plan.decisions.filter((d) => d.state === 'excluded')) say(` × ${decision.kind}/${decision.id} — ${decision.reason}`);
128
143
  say('Managed paths:');
129
144
  for (const path of plan.governedPaths) say(` ${path}`);
145
+ if (plan.replacesUserSkills?.length) {
146
+ say('Your own skills this plan replaces:');
147
+ for (const path of plan.replacesUserSkills) say(` ! ${path} — already here and not put by rsc; the catalog skill of the same name takes its place, and yours is kept in .rsc/backups/`);
148
+ say(' To keep yours, rename its folder (for example review → review-own) and generate the plan again.');
149
+ say(' AGENT: name each of these to the user before accepting, and ask whether to replace or rename.');
150
+ }
130
151
  if (plan.evidence.parentHarness) say(`Parent harness detected at ${plan.evidence.parentHarness}; it is not inherited by this plan.`);
131
152
  const pieces = [
132
153
  'npx @damiangilgonzalez/harness@latest onboard',
@@ -186,6 +207,11 @@ async function runOnboarding(targets) {
186
207
  return;
187
208
  }
188
209
  const plan = buildOnboardingPlan(record, scanProject(process.cwd()));
210
+ // #275 — a folder where a catalog skill goes that rsc did not put is the user's own work. It goes
211
+ // INTO the plan, so the id they accept names it: an id made before that folder existed no longer
212
+ // applies, and accepting is never consent to something the plan did not show.
213
+ const replacesUserSkills = ownSkillPaths({ cwd: process.cwd(), targets: plan.policy.targets, skillIds: plan.policy.skills });
214
+ if (replacesUserSkills.length) plan.replacesUserSkills = replacesUserSkills;
189
215
  const planId = identifyPlan(plan);
190
216
  const accepted = flag('accept-plan');
191
217
  if (!accepted) {
@@ -214,6 +240,10 @@ async function runOnboarding(targets) {
214
240
  } catch (error) {
215
241
  say(`RSC_SKELETON_FAILED ${error.message}`);
216
242
  }
243
+ for (const path of plan.replacesUserSkills || []) {
244
+ const kept = whereKept({ cwd: process.cwd(), relPath: path });
245
+ if (kept) say(`Your previous ${path} is kept in ${kept}`);
246
+ }
217
247
  emitHarnessReadiness(plan, planId);
218
248
  }
219
249
 
@@ -486,7 +516,18 @@ function printContextBudget(b) {
486
516
  say('════════════════════════════════════════════════\n');
487
517
  }
488
518
 
489
- async function wizard(flagTargets) {
519
+ async function syncDeclared(targets, dry = false) {
520
+ for (const t of targets) {
521
+ const result = await syncInstalled({ target: t, dryRun: dry });
522
+ const verb = dry ? 'Would sync' : 'Synced';
523
+ say(`${verb} ${t}: ${result.synced.length ? result.synced.join(', ') : '(nothing to sync)'}`);
524
+ if (dry && result.paths?.length) {
525
+ for (const p of result.paths) say(` ${p}`);
526
+ }
527
+ }
528
+ }
529
+
530
+ async function wizard(flagTargets, declaredTargets = []) {
490
531
  const m = loadManifest();
491
532
  await banner(m.counts.skills);
492
533
  say(' the skill catalog for your assistant (Claude Code · Codex · Cursor · Gemini · Antigravity)\n');
@@ -496,12 +537,16 @@ async function wizard(flagTargets) {
496
537
  const baseIds = skillsForProfile(m, 'minimal');
497
538
  // Navigable loop: esc / "← Back" / "no" all return here instead of quitting.
498
539
  for (;;) {
540
+ const declared = readManifest();
499
541
  const choice = await select('What do you want to do?', [
542
+ // First, because it is what someone who already has a harness came here for nine times in ten.
543
+ ...(declared ? [{ key: 'update', label: `Update this project — keeps its ${declared.skills.length} skills, refreshes hooks and content` }] : []),
500
544
  { key: 'base', label: `Base install — the essentials (${baseIds.length} skills)` },
501
545
  { key: 'workflow', label: 'Base + tu flujo de trabajo — grill-with-docs → to-spec → plan o tickets' },
502
546
  { key: 'manual', label: 'Pick skills by hand, by area' },
503
547
  ]);
504
548
  if (choice === null) { say('\nOK — nothing installed. Anytime: npx @damiangilgonzalez/harness'); return; }
549
+ if (choice === 'update') return syncDeclared(declaredTargets);
505
550
 
506
551
  let ids;
507
552
  if (choice === 'base') ids = baseIds;
@@ -560,6 +605,27 @@ async function guardCollisions(targets, ids) {
560
605
  return confirm('Replace them anyway?');
561
606
  }
562
607
 
608
+ // The governed list that `sync` reads lives INSIDE the onboarding receipt, not in `manifest.skills`.
609
+ // Updating one without the other is what deleted users' skills: `add` installed a skill and recorded
610
+ // it in `manifest.skills`, `sync` then read a governed list that predated the install, and pruned
611
+ // everything absent from it — recursively. Reported by a user, reproduced 2026-09-17: nine declared
612
+ // skills against eight governed, visible in the manifest before sync was even run.
613
+ //
614
+ // So the two travel together, always. Any command that changes what is installed carries both, and
615
+ // `applyInstall` persists the receipt rather than conserving the stale one.
616
+ function governedBy(receipt, skills) {
617
+ // The receipt is hash-checked against the plan the user accepted, so its policy is NOT a place to
618
+ // record what happened afterwards: rewriting `plan.policy.skills` made every install report "the
619
+ // receipt does not match its accepted plan id", which is the integrity check doing its job.
620
+ //
621
+ // An earlier fix for the reported data loss did exactly that, and it was wrong — the right place
622
+ // was never `add`, it was `sync`, which treated the accepted plan as the only declaration and
623
+ // pruned everything absent from it. The policy still travels for hooks and agents; what must not
624
+ // travel is a rewritten receipt.
625
+ if (!receipt) return { policy: undefined, onboarding: undefined };
626
+ return { policy: { ...receipt.plan.policy, skills }, onboarding: undefined };
627
+ }
628
+
563
629
  async function main() {
564
630
  // One resolution for every command, doctor included: `doctor` used to resolve on its
565
631
  // own and could report a different assistant than the one just installed into.
@@ -589,7 +655,16 @@ async function main() {
589
655
  const target = targets[0];
590
656
  switch (cmd) {
591
657
  case undefined:
592
- return hasDeclaredHarness() ? wizard(f ? targets : null) : runOnboarding(f ? targets : []);
658
+ if (!hasDeclaredHarness()) return runOnboarding(f ? targets : []);
659
+ // A project that already has a harness: the bare command means "bring it up to date". It is
660
+ // what every update notice, the README and our own replies to users tell people to run, with
661
+ // the promise "that reinstalls and refreshes". It used to open the install wizard instead, and
662
+ // with no terminal — which is how an agent runs it when it obeys the notice — it did nothing.
663
+ // Restoring what the project already declared is not a decision, so there is nothing to ask.
664
+ // RSC_FORCE_WIZARD opens the wizard anyway, answering from piped stdin: that is how its
665
+ // choices are tested end to end, since a test has no terminal to offer.
666
+ if (!isInteractive() && !process.env.RSC_FORCE_WIZARD) return syncDeclared(targets);
667
+ return wizard(f ? targets : null, targets);
593
668
  case 'onboard':
594
669
  return runOnboarding(f ? targets : []);
595
670
  case 'reassess':
@@ -604,8 +679,8 @@ async function main() {
604
679
  const currentManifest = readManifest();
605
680
  const receipt = currentManifest?.onboarding;
606
681
  const maintainedIds = receipt ? [...new Set([...(currentManifest.skills || []), ...(receipt.plan.policy.skills || []), ...ids])].sort() : ids;
607
- const policy = receipt ? { ...receipt.plan.policy, skills: maintainedIds } : undefined;
608
- for (const t of targets) await applyInstall({ skillIds: maintainedIds, agentIds: selected.agents, target: t, policy });
682
+ const { policy, onboarding } = governedBy(receipt, maintainedIds);
683
+ for (const t of targets) await applyInstall({ skillIds: maintainedIds, agentIds: selected.agents, target: t, policy, onboarding });
609
684
  markMaintenanceDrift(`add ${requested.join(',')}`);
610
685
  say(`✅ Installed for ${targets.join(', ')}: ${requested.join(', ')}`);
611
686
  return void say(' ↻ Reload/restart your assistant so the new skill activates.');
@@ -675,6 +750,8 @@ async function main() {
675
750
  }
676
751
  case 'doctor': {
677
752
  const report = doctor({ target });
753
+ // #277 — an automation on top of rsc reads the exit code, not the prose.
754
+ if (!report.healthy) process.exitCode = 1;
678
755
  if (argv.includes('--json')) return void say(JSON.stringify(report, null, 2));
679
756
  printContextBudget(report.contextBudget);
680
757
  return void say(JSON.stringify({ ...report, contextBudget: undefined }, null, 2));
@@ -727,18 +804,8 @@ async function main() {
727
804
  process.exitCode = 1;
728
805
  return;
729
806
  }
730
- case 'sync': {
731
- const dry = argv.includes('--dry-run');
732
- for (const t of targets) {
733
- const result = await syncInstalled({ target: t, dryRun: dry });
734
- const verb = dry ? 'Would sync' : 'Synced';
735
- say(`${verb} ${t}: ${result.synced.length ? result.synced.join(', ') : '(nothing to sync)'}`);
736
- if (dry && result.paths?.length) {
737
- for (const p of result.paths) say(` ${p}`);
738
- }
739
- }
740
- return;
741
- }
807
+ case 'sync':
808
+ return syncDeclared(targets, argv.includes('--dry-run'));
742
809
  case 'backups': {
743
810
  const backups = listBackups();
744
811
  if (!backups.length) return void say('(no backups)');
@@ -778,6 +845,37 @@ async function main() {
778
845
  say('Use: npx @damiangilgonzalez/harness registry refresh | registry status');
779
846
  return;
780
847
  }
848
+ case 'review': {
849
+ // `review consolidate` — the deterministic half of the refuter panel (P1): the three lenses
850
+ // often report one defect three ways, so grouping happens here, by location and evidence, and
851
+ // only the unique serious findings go on to `finding-verifier`, which is judgement.
852
+ const USE = 'Use: npx @damiangilgonzalez/harness review consolidate [report.md|findings.json ...] [--json] [--window <lines>]\n Without files it reads stdin. Each lens report carries its findings in ```json findings blocks.';
853
+ if (argv[1] !== 'consolidate') { say(USE); if (argv[1]) process.exitCode = 1; return; }
854
+ const { readFileSync } = await import('node:fs');
855
+ const R = await import('./lib/review-findings.js');
856
+ const w = flag('window');
857
+ const window = w === undefined ? R.DEFAULT_WINDOW : Number(w);
858
+ if (!Number.isInteger(window) || window < 0) { say(`rsc: --window takes a whole number of lines, got '${w}'.\n${USE}`); process.exitCode = 1; return; }
859
+ const rest = argv.slice(2);
860
+ const files = rest.filter((a, i) => !a.startsWith('--') && rest[i - 1] !== '--window');
861
+ const sources = [];
862
+ try {
863
+ if (files.length) for (const f of files) sources.push([readFileSync(f, 'utf8'), f]);
864
+ else if (!process.stdin.isTTY) sources.push([readFileSync(0, 'utf8'), 'stdin']);
865
+ } catch (e) { say(`rsc: cannot read ${e.path || 'stdin'} (${e.code || e.message}).`); process.exitCode = 1; return; }
866
+ if (!sources.length) { say(USE); process.exitCode = 1; return; }
867
+ const all = []; const errors = [];
868
+ for (const [raw, src] of sources) {
869
+ const parsed = R.parseFindings(raw, src);
870
+ all.push(...parsed.findings); errors.push(...parsed.errors);
871
+ }
872
+ const result = R.consolidate(all, { window });
873
+ if (argv.includes('--json')) say(JSON.stringify({ ...result, errors }, null, 2));
874
+ else say(R.renderConsolidation(result, errors));
875
+ // An unreadable block is a finding that may have been lost: say it with the exit code too.
876
+ if (errors.length) process.exitCode = 1;
877
+ return;
878
+ }
781
879
  case 'worktrees': {
782
880
  // The deterministic half of "the cleanup is the default". `ship` and the `worktrees` skill call
783
881
  // this instead of judging by eye, which is what turns a rule that was only ever written down
@@ -3,6 +3,7 @@ name: animar
3
3
  description: "Use when asked to animate something, add motion, make a component feel alive, or build a transition. Builds the animation from scratch, making the decisions in the order that determines whether it feels right — whether it should animate at all, for what purpose, with which tool, which properties, which curve and duration, how it is interrupted, and how it exits. Writes the implementation. For critiquing existing motion use revisar-animaciones."
4
4
  tags: ["animation", "motion", "transitions"]
5
5
  profiles: [ui, full]
6
+ origin: damiangil
6
7
  ---
7
8
 
8
9
  # Construir animaciones
@@ -3,6 +3,7 @@ name: claude-project-setup
3
3
  description: "Use when starting a new project, initializing a repository for Claude Code, or when the user asks to configure CLAUDE.md, rules, commands, skills, or agents for a project."
4
4
  tags: ["setup", "claude-code", "project-init"]
5
5
  profiles: [core, ui, full]
6
+ origin: damiangil
6
7
  ---
7
8
 
8
9
  # Claude Project Setup
@@ -3,6 +3,7 @@ name: code-review
3
3
  description: "Reviews changes from a fixed point (commit, branch, tag, or merge-base) along two axes — Standards (does the code follow the repo's documented coding standards?) and Spec (does the code match what the source issue/PRD asked for?). Runs both reviews in parallel sub-agents and reports them side by side. Use when the user wants to review a branch, a PR, work in progress, or asks to \\"review from X\\"."
4
4
  tags: ["code-review", "standards", "spec-compliance"]
5
5
  profiles: [core, ui, full]
6
+ origin: damiangil
6
7
  ---
7
8
 
8
9
  Revisión en dos ejes del diff entre `HEAD` y un punto fijo que proporciona el usuario:
@@ -75,7 +76,23 @@ Envía un único mensaje con dos llamadas a la herramienta `Agent`. Usa el subag
75
76
 
76
77
  Si falta el spec, sáltate el sub-agente de Spec y anótalo en el reporte final.
77
78
 
78
- ### 5. Agregar
79
+ ### 5. Verificar los graves antes de presentarlos
80
+
81
+ Un sub-agente que busca problemas puede exagerar uno. Antes de agregar, **dentro de cada eje
82
+ por separado** (nunca mezclando Estándares con Spec):
83
+
84
+ 1. **Agrupar.** Pide a cada sub-agente que termine con un bloque ` ```json findings ` (`lens`, `severity`
85
+ blocker|should-fix|nit|question, `file`, `line`, `claim`, `failure_scenario`, `evidence`). Guarda
86
+ cada reporte y ejecuta `npx @damiangilgonzalez/harness review consolidate <reporte>`. Junta los
87
+ duplicados y baja a pregunta un blocker o should-fix sin `file:line` o sin escenario de fallo.
88
+ 2. **Verificar.** Cada hallazgo que marque `verify` va al agente **`finding-verifier`** (si el
89
+ proyecto no lo tiene, a un `general-purpose` con esa misma postura): contexto limpio, parte de que
90
+ el hallazgo es falso y devuelve **CONFIRMED** (con la prueba), **REFUTED** (cita lo que lo hace
91
+ imposible) o **UNPROVEN** (pasa a pregunta). Solo CONFIRMED se presenta como grave.
92
+
93
+ Di la cuenta con honestidad ("7 reportados → 5 únicos → 2 confirmados") y lista lo refutado y por qué.
94
+
95
+ ### 6. Agregar
79
96
 
80
97
  Presenta los dos reportes bajo los encabezados `## Estándares` y `## Spec`, verbatim o ligeramente limpiados. **No** fusiones ni reordenes los hallazgos — los dos ejes son deliberadamente separados (ver _Por qué dos ejes_).
81
98
 
@@ -3,6 +3,7 @@ name: diseno-apple
3
3
  description: "Apple's approach to interface design and fluid, physical motion, translated to the web. Use when building or reviewing gesture-driven UI, spring animations, drag/swipe/sheet interactions, inertia and interruptible transitions, translucent materials and depth, typography (optical size, tracking, line height), reduced motion, or the design fundamentals (feedback, spatial consistency, containment) behind Apple-style interfaces."
4
4
  tags: ["design", "apple", "motion", "interface"]
5
5
  profiles: [ui, full]
6
+ origin: damiangil
6
7
  ---
7
8
 
8
9
  # Diseno Apple
@@ -3,6 +3,7 @@ name: diseno-landing
3
3
  description: "Complete system for building high-conversion landing pages: intake questions, page structure, layout choice, conversion copy, SEO, plus strict visual rules for typography, spacing, radii, backgrounds, hero, icons, and motion. Use whenever building, editing, styling, reviewing, or writing copy for ANY landing page, marketing site, web UI, page section, component, or prototype, even if the user does not mention design, conversion, tokens, or a design system. Always consult it before choosing sections, headlines, CTAs, fonts, text sizes, spacing values, radii, background colors, icons, or transitions."
4
4
  tags: ["landing-page", "conversion", "design-system", "copywriting"]
5
5
  profiles: [ui, full]
6
+ origin: damiangil
6
7
  ---
7
8
 
8
9
  # Diseno de landing page
@@ -3,6 +3,7 @@ name: domain-modeling
3
3
  description: "Build and refine the project's domain model. Use when the user wants to pin down domain terminology or a ubiquitous language, record an architectural decision, or when another skill needs to keep the domain model up to date."
4
4
  tags: ["domain-modeling", "glossary", "architecture"]
5
5
  profiles: [core, ui, full]
6
+ origin: damiangil
6
7
  ---
7
8
 
8
9
  # Modelado de Dominio
@@ -4,6 +4,7 @@ description: "Use when work touches a screen — building or reshaping a landing
4
4
  tags: ["ui-design", "routing", "workflow"]
5
5
  profiles: [ui, full]
6
6
  recommends: [ingeniero-diseno-web, diseno-landing, revision-interfaz]
7
+ origin: damiangil
7
8
  ---
8
9
 
9
10
  # Flujo de interfaz — quien manda en cada paso