@ericrisco/rsc 3.0.6 → 3.0.8

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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { rmSync, existsSync, cpSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, appendFileSync, readdirSync } from 'node:fs';
2
+ import { rmSync, rmdirSync, existsSync, cpSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, appendFileSync, readdirSync, renameSync } from 'node:fs';
3
3
  import { join, dirname, relative, resolve, sep } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { spawnSync } from 'node:child_process';
@@ -16,6 +16,7 @@ import { withDefaultSkillFloor } from './lib/default-skill-floor.js';
16
16
  import { isRetired, replaceRetired } from './lib/retired-skills.js';
17
17
  import { readManifest, writeManifest } from './lib/manifest-file.js';
18
18
  import { createBackup } from './lib/backups.js';
19
+ import { removeUnmodifiedSkeleton } from './lib/tools-skeleton.js';
19
20
  import {
20
21
  targetHasCommands, resolveCommands, reconcileCommands, commandPath, allPotentialCommandNames,
21
22
  } from '../targets/commands.js';
@@ -25,6 +26,7 @@ import {
25
26
  import { wireUpdate, unwireUpdate, updateManagedPaths, updateArtifactsPresent } from '../targets/update-wiring.js';
26
27
  import { wireKnowledge, unwireKnowledge, knowledgeManagedPaths, knowledgeArtifactsPresent } from '../targets/knowledge-wiring.js';
27
28
  import { wireGitPermissions, unwireGitPermissions, gitPermissionsPath } from '../targets/git-permissions.js';
29
+ import { wireDeepseekBridge } from '../targets/deepseek-bridge.js';
28
30
 
29
31
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
30
32
  const CLI_VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
@@ -80,6 +82,7 @@ export function generatedHookFiles({ target, cwd, policy }) {
80
82
  join(cwd, '.rsc', 'session-start.mjs'),
81
83
  join(cwd, '.rsc', 'worklog-checkpoint.mjs'),
82
84
  join(cwd, '.rsc', 'hook-once.mjs'),
85
+ join(cwd, '.rsc', 'shell-unwrap.mjs'),
83
86
  join(cwd, '.rsc', 'worktree-reaper.mjs'),
84
87
  join(cwd, '.rsc', 'auto-update.mjs'),
85
88
  // team-safe-default: the 3.0 start-up duties and the trunk policy (also read by branch-guard).
@@ -89,12 +92,14 @@ export function generatedHookFiles({ target, cwd, policy }) {
89
92
  // The danger guard is declared on its own terms (#273): present unless the plan says otherwise,
90
93
  // whether or not the code guards are.
91
94
  const danger = policy?.dangerGuard === false ? [] : [join(cwd, '.rsc', 'danger-guard.mjs')];
92
- if (policy?.codeHooks === false) return [...lifecycle, ...danger, join(cwd, '.rsc', 'suggest-always-on.md')];
95
+ const laneGate = policy?.laneGate ?? policy?.codeHooks !== false;
96
+ const lane = laneGate ? [join(cwd, '.rsc', 'userprompt-gate.mjs'), join(cwd, '.rsc', 'ftd-nudge.mjs')] : [];
97
+ if (policy?.codeHooks === false) return [...lifecycle, ...danger, ...(laneGate ? lane : [join(cwd, '.rsc', 'suggest-always-on.md')])];
93
98
  return [...lifecycle, ...danger,
94
99
  join(cwd, '.rsc', 'ship-guard.mjs'),
95
100
  join(cwd, '.rsc', 'branch-guard.mjs'),
96
101
  join(cwd, '.rsc', 'gitmoji-guard.mjs'), join(cwd, '.rsc', 'userprompt-gate.mjs'),
97
- join(cwd, '.rsc', 'sello.mjs')];
102
+ join(cwd, '.rsc', 'sello.mjs'), ...(laneGate ? [join(cwd, '.rsc', 'ftd-nudge.mjs')] : [])];
98
103
  }
99
104
 
100
105
  export function managedPathsForInstall({ skillIds, agentIds = [], target, home, cwd, policy }) {
@@ -320,6 +325,8 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
320
325
  // the memory, so the memory's policy does not decide it: wired wherever the assistant can run it.
321
326
  const knowledgeResult = wireKnowledge(target, cwd);
322
327
  state.knowledge = { mode: knowledgeResult.mode, reason: knowledgeResult.reason || null, paths: knowledgeResult.paths };
328
+ // DeepSeek Harness only reads hooks through a machine-level bridge (targets/deepseek-bridge.js).
329
+ if (target === 'deepseek') state.deepseekBridge = wireDeepseekBridge();
323
330
  if (gitPermissions === true) state.gitPermissions = wireGitPermissions(target, cwd);
324
331
  else if (gitPermissions === false) { unwireGitPermissions(target, cwd); state.gitPermissions = { mode: 'off' }; }
325
332
  const context7OptOut = join(cwd, '.rsc', '.no-context7');
@@ -332,6 +339,7 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
332
339
  alwaysOn: policy.alwaysOn !== false,
333
340
  codeHooks: policy.codeHooks !== false,
334
341
  dangerGuard: policy.dangerGuard !== false,
342
+ laneGate: policy.laneGate ?? policy.codeHooks !== false,
335
343
  gitmojiGuard: policy.gitmojiGuard !== false,
336
344
  memory: policy.memory !== false,
337
345
  context7: policy.context7 === true,
@@ -399,7 +407,12 @@ export function ignoreLocalState(cwd = process.cwd(), target) {
399
407
  // whole directory would quietly stop versioning their work, which is the same sin this
400
408
  // release exists to fix: treating what is theirs as if it were ours. So shared
401
409
  // directories are excluded entry by entry, and only the entries rsc manages.
402
- const wanted = ['.rsc/'];
410
+ // `.worktrees/` is where the isolation rule sends every concurrent session (`git worktree add
411
+ // .worktrees/<branch>`). Nothing ignored it, so in a teammate's clone it surfaced as `?? .worktrees/`
412
+ // — one `git add -A` from committing a whole checkout (team simulation D9). It goes in THIS block,
413
+ // the committed one, so every clone inherits it with its first pull; `.git/info/exclude` would
414
+ // only ever cover the machine that ran the installer.
415
+ const wanted = ['.rsc/', '.worktrees/'];
403
416
  if (target) {
404
417
  const paths = targetPaths(target, undefined, cwd);
405
418
  const rel = (abs) => relative(cwd, abs).split(sep).join('/');
@@ -694,6 +707,11 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
694
707
  policy: manifest?.onboarding?.plan?.policy,
695
708
  onboarding: manifest?.onboarding,
696
709
  });
710
+ // A teammate never runs onboarding — the harness arrives in the clone — so the exclude onboarding
711
+ // writes for the personal profile never reached them, and their first answer to the one onboarding
712
+ // question sat untracked, one `git add -A` from the team repo (team simulation D13). Sync is what a
713
+ // clone runs, so it writes the same exclude. Lazy import: onboarding-apply imports this module.
714
+ try { (await import('./lib/onboarding-apply.js')).excludePersonalProfile(cwd); } catch { /* not a repo → nothing */ }
697
715
  return { synced: declared, syncedAgents: declaredAgents, backup: nextState.backup };
698
716
  }
699
717
 
@@ -703,13 +721,58 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
703
721
  // own knowledge — kept unless `withDocs` is set. Returns the paths touched.
704
722
  // Note: backups live under `.rsc/backups/`, which this removes — so purge does not
705
723
  // snapshot (a pre-purge backup would delete itself). It is the deliberate escape hatch.
706
- export async function purge({ home, cwd = process.cwd(), withDocs = false, dryRun = false } = {}) {
707
- const removed = [];
724
+ export async function purge(options = {}) {
725
+ const report = await purgeReport(options);
726
+ return [...report.removed, ...report.cleaned];
727
+ }
728
+
729
+ // A config file whose only content was rsc's is not "cleaned" when the rsc entries leave it as `{}`:
730
+ // it is a stub nobody asked for, and calling it removed while it sits on disk is the lie the E2E
731
+ // caught. `$schema` and `version` are format boilerplate rsc itself adds, not user content.
732
+ function emptyConfig(file) {
733
+ let value;
734
+ try { value = JSON.parse(readFileSync(file, 'utf8')); } catch { return false; }
735
+ const empty = (v) => (Array.isArray(v) ? v.every(empty)
736
+ : v && typeof v === 'object' ? Object.values(v).every(empty) : false);
737
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
738
+ const rest = Object.fromEntries(Object.entries(value).filter(([k]) => k !== '$schema' && k !== 'version'));
739
+ return empty(rest);
740
+ }
741
+
742
+ const RSC_IGNORE_HEADER = '# rsc local state (hooks, seals, logs) and managed skill links — machine-local';
743
+
744
+ // Remove the blocks `ignoreLocalState` appended: the header line, the entries under it up to the
745
+ // next blank line, and the blank seam before it. Lines the user wrote are never part of a block.
746
+ function stripIgnoreBlocks(text) {
747
+ const lines = text.split('\n');
748
+ const out = [];
749
+ for (let i = 0; i < lines.length; i++) {
750
+ if (lines[i] !== RSC_IGNORE_HEADER) { out.push(lines[i]); continue; }
751
+ if (out.length && out[out.length - 1] === '') out.pop();
752
+ while (i + 1 < lines.length && lines[i + 1].trim() !== '') i++;
753
+ }
754
+ return out.join('\n');
755
+ }
756
+
757
+ /**
758
+ * Remove EVERYTHING rsc put in this project and say exactly what happened to each thing:
759
+ * removed — gone from disk;
760
+ * cleaned — rsc's entries taken out, the file kept because something of the user's remains;
761
+ * kept — left on purpose, with the reason (and the command, when it is the user's call).
762
+ * `02-DOCS/` is the user's own knowledge — kept unless `withDocs` is set. Remote branches are never
763
+ * deleted from here. Backups live under `.rsc/backups/`, which this removes — so purge does not
764
+ * snapshot (a pre-purge backup would delete itself). It is the deliberate escape hatch.
765
+ */
766
+ export async function purgeReport({ home, cwd = process.cwd(), withDocs = false, dryRun = false } = {}) {
767
+ const removed = new Set();
768
+ const cleaned = new Set();
769
+ const kept = [];
708
770
  const drop = (p, recursive = false) => {
709
- if (!existsSync(p)) return;
710
- removed.push(p);
771
+ if (!existsSync(p) && !isLink(p)) return;
772
+ removed.add(p);
711
773
  if (!dryRun) rmSync(p, { recursive, force: true });
712
774
  };
775
+ const touchedConfigs = new Set();
713
776
  for (const target of TARGET_IDS) {
714
777
  const paths = targetPaths(target, home, cwd);
715
778
  if (existsSync(paths.stateFile)) {
@@ -727,25 +790,168 @@ export async function purge({ home, cwd = process.cwd(), withDocs = false, dryRu
727
790
  }
728
791
  drop(paths.stateFile);
729
792
  }
793
+ const bootstrap = join(paths.projectRoot || cwd, '.claude', 'rsc-bootstrap.mjs');
794
+ const hadBootstrap = target === 'claude' && existsSync(bootstrap);
730
795
  // Unwiring mutates shared config files, so only run it for real (dry runs report
731
796
  // those files without touching them).
732
797
  if (!dryRun) {
733
- removed.push(...unwireHook(target, paths));
734
- removed.push(...unwireMemory(target, cwd));
735
- removed.push(...unwireUpdate(target, cwd));
736
- removed.push(...unwireKnowledge(target, cwd));
737
- removed.push(...unwireGitPermissions(target, cwd));
798
+ for (const p of [
799
+ ...unwireHook(target, paths),
800
+ ...unwireMemory(target, cwd),
801
+ ...unwireUpdate(target, cwd),
802
+ ...unwireKnowledge(target, cwd),
803
+ ...unwireGitPermissions(target, cwd),
804
+ ]) touchedConfigs.add(p);
805
+ if (hadBootstrap && !existsSync(bootstrap)) removed.add(bootstrap);
738
806
  } else {
739
- removed.push(...memoryArtifactsPresent(target, cwd));
740
- removed.push(...updateArtifactsPresent(target, cwd));
741
- removed.push(...knowledgeArtifactsPresent(target, cwd));
807
+ for (const p of [
808
+ ...memoryArtifactsPresent(target, cwd),
809
+ ...updateArtifactsPresent(target, cwd),
810
+ ...knowledgeArtifactsPresent(target, cwd),
811
+ ]) touchedConfigs.add(p);
812
+ if (paths.hookTarget && existsSync(paths.hookTarget)
813
+ && /\.rsc\/|rsc-suggest:start|skills\/rsc\/suggest/.test(readFileSync(paths.hookTarget, 'utf8').replaceAll('\\\\', '/'))) {
814
+ touchedConfigs.add(paths.hookTarget);
815
+ }
816
+ if (hadBootstrap) removed.add(bootstrap);
742
817
  }
743
818
  // A lost state file also loses proof of ownership. Leave same-named user files
744
819
  // behind rather than guessing from a catalog id and deleting their work.
745
820
  }
746
- drop(join(cwd, '.rsc'), true);
821
+ const rscDir = join(cwd, '.rsc');
822
+ for (const p of touchedConfigs) {
823
+ if (p === rscDir || p.startsWith(`${rscDir}${sep}`)) { removed.add(p); continue; }
824
+ if (dryRun) { if (existsSync(p)) cleaned.add(p); continue; }
825
+ if (!existsSync(p)) { removed.add(p); continue; }
826
+ let body = '';
827
+ try { body = readFileSync(p, 'utf8'); } catch { /* unreadable: leave it, call it cleaned */ }
828
+ if (/\.json$/i.test(p) ? emptyConfig(p) : body.trim() === '') {
829
+ rmSync(p, { force: true });
830
+ removed.add(p);
831
+ } else cleaned.add(p);
832
+ }
833
+
834
+ // The `.gitignore` block rsc appended. The file goes only if nothing of the user's is left in it.
835
+ const gitignore = join(cwd, '.gitignore');
836
+ if (existsSync(gitignore)) {
837
+ const text = readFileSync(gitignore, 'utf8');
838
+ if (text.includes(RSC_IGNORE_HEADER)) {
839
+ const rest = stripIgnoreBlocks(text);
840
+ if (rest.trim() === '') { removed.add(gitignore); if (!dryRun) rmSync(gitignore, { force: true }); }
841
+ else { cleaned.add(gitignore); if (!dryRun) writeFileSync(gitignore, rest.endsWith('\n') ? rest : `${rest}\n`); }
842
+ }
843
+ }
844
+
845
+ // The committed declaration of the harness that no longer exists.
846
+ drop(join(cwd, '.rsc.json'));
847
+
848
+ // The `01-TOOLS/` scaffolding onboarding wrote, only where nobody has touched it.
849
+ const skeleton = removeUnmodifiedSkeleton(cwd, { dryRun });
850
+ for (const rel of skeleton.removed) removed.add(join(cwd, rel));
851
+ for (const k of skeleton.kept) kept.push(k);
852
+
853
+ drop(rscDir, true);
747
854
  if (withDocs) drop(join(cwd, '02-DOCS'), true);
748
- return [...new Set(removed)];
855
+ else if (existsSync(join(cwd, '02-DOCS'))) kept.push({ path: '02-DOCS/', reason: 'your knowledge base. Add --with-docs to remove it too.' });
856
+
857
+ // Lines rsc added to `.git/info/exclude` for files that are now gone.
858
+ const exclude = gitExcludeFile(cwd);
859
+ if (exclude && existsSync(exclude)) {
860
+ const gone = new Set([...removed].map((p) => `/${relative(cwd, p).split(sep).join('/')}`));
861
+ const lines = readFileSync(exclude, 'utf8').split('\n');
862
+ const keptLines = lines.filter((l) => !(gone.has(l.trim()) || l.trim().startsWith('/.rsc/')));
863
+ if (keptLines.length !== lines.length) {
864
+ cleaned.add(exclude);
865
+ if (!dryRun) writeFileSync(exclude, keptLines.join('\n'));
866
+ }
867
+ }
868
+
869
+ // Directories that only held what was just removed. Deepest first; never `cwd` itself.
870
+ const parents = new Set();
871
+ for (const p of removed) {
872
+ for (let d = dirname(p); d !== cwd && d.startsWith(`${cwd}${sep}`); d = dirname(d)) parents.add(d);
873
+ }
874
+ const ordered = [...parents].sort((a, b) => b.split(sep).length - a.split(sep).length);
875
+ const willBeEmpty = (dir) => {
876
+ try {
877
+ return readdirSync(dir).every((name) => {
878
+ const child = join(dir, name);
879
+ return removed.has(child);
880
+ });
881
+ } catch { return false; }
882
+ };
883
+ for (const dir of ordered) {
884
+ if (!existsSync(dir) || isLink(dir)) continue;
885
+ if (dryRun ? willBeEmpty(dir) : readdirSync(dir).length === 0) {
886
+ if (!dryRun) rmdirSync(dir);
887
+ removed.add(dir);
888
+ }
889
+ }
890
+
891
+ // The knowledge exchange branch lives on the remote, which is shared: deleting it is the user's
892
+ // call, so say it exists and hand them the command.
893
+ for (const ref of gitLines(cwd, ['for-each-ref', '--format=%(refname)', 'refs/remotes/*/rsc/knowledge', 'refs/heads/rsc/knowledge'])) {
894
+ const remote = /^refs\/remotes\/([^/]+)\/rsc\/knowledge$/.exec(ref)?.[1];
895
+ if (remote) kept.push({ path: `${remote}/rsc/knowledge`, reason: `remote branch; rsc never deletes remote branches. To delete it: git push ${remote} --delete rsc/knowledge` });
896
+ else kept.push({ path: 'rsc/knowledge', reason: 'local branch. To delete it: git branch -D rsc/knowledge' });
897
+ }
898
+
899
+ // The post-merge hook rsc installs (worktree-reaper.mjs). It lives in git's private hooks dir, so it
900
+ // is per clone, never committed — and left behind it kept calling a reaper that no longer exists.
901
+ // Only a hook carrying rsc's marker is touched; one rsc had chained aside is put back in place.
902
+ const [hooks] = gitLines(cwd, ['rev-parse', '--path-format=absolute', '--git-path', 'hooks']);
903
+ const hook = hooks ? join(hooks, 'post-merge') : null;
904
+ if (hook && existsSync(hook)) {
905
+ let body = '';
906
+ try { body = readFileSync(hook, 'utf8'); } catch { /* unreadable → not provably ours */ }
907
+ if (body.includes('# rsc-managed worktree cleanup (post-merge)')) {
908
+ const chained = join(hooks, 'post-merge.rsc-local');
909
+ if (existsSync(chained)) {
910
+ cleaned.add(hook);
911
+ if (!dryRun) { rmSync(hook, { force: true }); renameSync(chained, hook); }
912
+ } else drop(hook);
913
+ }
914
+ }
915
+
916
+ // The project's instruction files are never purge's to delete. Say so, so the report accounts for them.
917
+ for (const name of ['CLAUDE.md', 'AGENTS.md']) {
918
+ const file = join(cwd, name);
919
+ if (existsSync(file) && !removed.has(file) && !cleaned.has(file)) {
920
+ kept.push({ path: name, reason: 'your project instructions; purge never deletes them. If they still mention rsc or 02-DOCS, edit those lines by hand.' });
921
+ }
922
+ }
923
+
924
+ // A path that is both removed and cleaned was cleaned, then emptied, then removed.
925
+ for (const p of removed) cleaned.delete(p);
926
+ // `.rsc/` is rsc's own directory and goes whole: listing its files one by one adds noise, not truth.
927
+ if (removed.has(rscDir)) for (const p of [...removed]) if (p.startsWith(`${rscDir}${sep}`)) removed.delete(p);
928
+
929
+ // What of the removed or cleaned is COMMITTED. On a teammate's clone that is most of it (.rsc.json,
930
+ // .claude/settings.json, the bootstrap…): changing it here is local, committing the changes
931
+ // uninstalls rsc for everyone who pulls (team simulation D15). The index still has them after the
932
+ // delete, so this reads the same in a dry run and a real one.
933
+ const gone = new Set([...removed, ...cleaned].map((p) => relative(cwd, p).split(sep).join('/')));
934
+ const under = (file) => { for (let d = file; d && d !== '.'; d = d.includes('/') ? d.slice(0, d.lastIndexOf('/')) : '') if (gone.has(d)) return true; return false; };
935
+ const listed = spawnSync('git', ['ls-files', '-z'], { cwd, encoding: 'utf8' });
936
+ const tracked = listed.status === 0
937
+ ? listed.stdout.split('\0').filter(Boolean).filter(under) : [];
938
+ return { removed: [...removed], cleaned: [...cleaned], kept, tracked };
939
+ }
940
+
941
+ function isLink(p) {
942
+ try { return lstatSync(p).isSymbolicLink(); } catch { return false; }
943
+ }
944
+
945
+ function gitLines(cwd, args) {
946
+ try {
947
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
948
+ return r.status === 0 ? r.stdout.split('\n').map((l) => l.trim()).filter(Boolean) : [];
949
+ } catch { return []; }
950
+ }
951
+
952
+ function gitExcludeFile(cwd) {
953
+ const [value] = gitLines(cwd, ['rev-parse', '--git-path', 'info/exclude']);
954
+ return value ? resolve(cwd, value) : null;
749
955
  }
750
956
 
751
957
  if (import.meta.url === `file://${process.argv[1]}`) {
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join, relative, resolve, sep } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
4
+ import { execFileSync } from 'node:child_process';
5
5
  import { applyInstall, pruneSharedBases, removeTargetInstall } from '../install-apply.js';
6
6
  import { targetPaths } from '../../targets/index.js';
7
7
  import { targetHasAgents } from '../../targets/agents.js';
@@ -10,6 +10,7 @@ import { readManifest, writeManifest } from './manifest-file.js';
10
10
  import { encodeGoal, identifyPlan } from './onboarding.js';
11
11
  import { createBackup, restoreBackup } from './backups.js';
12
12
  import { RETIRED_SKILLS, replaceRetired } from './retired-skills.js';
13
+ import { LAYER_IGNORE, TEMPLATE_SOURCE, targetName, templateAssets } from './tools-skeleton.js';
13
14
 
14
15
  const sameSet = (a = [], b = []) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
15
16
 
@@ -157,12 +158,35 @@ export function mergeProfile(existing, plan, previousRecord) {
157
158
  return `---\n${front.join('\n')}\n---\n${body.startsWith('\n') ? '' : '\n'}${body}`;
158
159
  }
159
160
 
161
+ /**
162
+ * The profile is one person's dials (knowledge-sync never sends it), so it never belongs in a commit
163
+ * either — yet it sat untracked for good, and every session's agent reported it as «cambios sin
164
+ * commitear» (E2E 2026-10-07). Excluded in THIS clone only (`info/exclude`, not `.gitignore`), and
165
+ * never when the team already tracks it: that is their decision.
166
+ */
167
+ export function excludePersonalProfile(cwd) {
168
+ const git = (args) => { try { return execFileSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; } };
169
+ if (git(['rev-parse', '--is-inside-work-tree']) !== 'true') return false;
170
+ const rel = '02-DOCS/wiki/harness/user-profile.md';
171
+ if (git(['ls-files', '--', rel])) return false;
172
+ const value = git(['rev-parse', '--git-path', 'info/exclude']);
173
+ if (!value) return false;
174
+ const file = resolve(cwd, value);
175
+ const pattern = `/${rel}`;
176
+ mkdirSync(dirname(file), { recursive: true });
177
+ const body = existsSync(file) ? readFileSync(file, 'utf8') : '';
178
+ if (body.split('\n').includes(pattern)) return true;
179
+ appendFileSync(file, `${body && !body.endsWith('\n') ? '\n' : ''}${pattern}\n`);
180
+ return true;
181
+ }
182
+
160
183
  export function writeOnboardingDocuments(cwd, plan, planId, previousRecord = null) {
161
184
  const dir = join(cwd, '02-DOCS', 'wiki', 'harness');
162
185
  mkdirSync(dir, { recursive: true });
163
186
  const docs = renderOnboardingDocuments(plan, planId);
164
187
  const profilePath = join(dir, 'user-profile.md');
165
188
  writeFileSync(profilePath, mergeProfile(existsSync(profilePath) ? readFileSync(profilePath, 'utf8') : '', plan, previousRecord));
189
+ excludePersonalProfile(cwd);
166
190
  writeFileSync(join(dir, 'installation-plan.md'), docs.installation);
167
191
  const decisionsPath = join(dir, 'decisions.md');
168
192
  if (!existsSync(decisionsPath)) writeFileSync(decisionsPath, docs.decisions);
@@ -343,34 +367,13 @@ export async function applyAcceptedOnboarding({ cwd = process.cwd(), plan, planI
343
367
  // Y la retro-compatibilidad no es una lista de exentos (P3): el suelo viaja DENTRO del plan, así
344
368
  // que un recibo aceptado por una versión anterior no lo trae y queda exento por construcción.
345
369
 
346
- const LIB_DIR = dirname(fileURLToPath(import.meta.url));
347
- const TEMPLATE_SOURCE = join(LIB_DIR, '..', '..', 'skills', 'harness', 'assets', '_TEMPLATE');
348
370
  const TEMPLATE_FLOOR = '01-TOOLS/_TEMPLATE/';
349
371
 
350
372
  export const HARNESS_FLOOR_MINIMUM = [TEMPLATE_FLOOR, '02-DOCS/wiki/harness/'];
351
373
  export const HARNESS_FLOOR_CONSTITUTION = '02-DOCS/wiki/sdd/constitution.md';
352
374
 
353
- // Los ficheros que la plantilla trae hoy. Se lee del asset en vez de codificar una lista, para que
354
- // añadir un fichero a la plantilla no deje el suelo comprobando de menos en silencio.
355
- //
356
- // Un error aquí NO se convierte en lista vacía. Comérselo dejaba el suelo permanentemente
357
- // insatisfacible, con un mensaje que ofrecía una acción que leía ese mismo asset ausente y que por
358
- // tanto no podía arreglarlo nunca — y sin nombrar jamás la causa real, que es un paquete roto.
359
- function templateAssets() {
360
- return readdirSync(TEMPLATE_SOURCE).sort();
361
- }
362
-
363
- // npm NUNCA empaqueta un fichero llamado `.gitignore`, así que el asset viaja sin punto y se copia
364
- // con él. Sin esto, desde el paquete publicado se copiaban 4 de 5 ficheros y el que faltaba era el
365
- // único que evita comitear el `.env` que el README —copiado por el propio instalador— manda crear.
366
- const DOTTED = { gitignore: '.gitignore' };
367
- const targetName = (asset) => DOTTED[asset] ?? asset;
368
-
369
- // La capa entera, no cada proveedor: el flujo documentado es copiar `_TEMPLATE/` a
370
- // `01-TOOLS/<PROVEEDOR>/`, y depender de que cada copia se lleve su propia protección es depender
371
- // de que nadie se salte un paso con credenciales de por medio.
372
- const LAYER_IGNORE = ['# Escrito por rsc: la capa de herramientas guarda credenciales.',
373
- '*/.env', '*/.env.*', '!*/.env.example', '*/keys/', '*/out/', ''].join('\n');
375
+ // La plantilla, sus nombres con punto y el `.gitignore` de la capa viven en `tools-skeleton.js`:
376
+ // lo que se copia aquí es exactamente lo que `purge` puede recuperar, y sólo si sigue intacto.
374
377
 
375
378
  /**
376
379
  * Crea la parte determinista del esqueleto. Copiar ficheros estáticos es un algoritmo, así que por
@@ -248,7 +248,14 @@ export function buildOnboardingPlan(record, evidence) {
248
248
  // in an invoicing workspace, and `init` promises it by technical level: non-technical and mixed get
249
249
  // it, whatever the project is. The guard also decides at runtime from the profile, so the plan only
250
250
  // chooses whether it is present, never whether it bites.
251
- const dangerGuard = normalized.technicalLevel !== 'technical';
251
+ //
252
+ // And since 3.0.8 it is present for EVERYONE. A technical user used to get nothing, so a small
253
+ // project with `main` open had no guard at all (E2E 2026-10-07). The runtime still decides the
254
+ // bite: deny for non-technical, `ask` — only on lost work or rewritten history — for technical.
255
+ const dangerGuard = true;
256
+ // The FTD/SDD lane decision belongs to every software project, small ones included: without it
257
+ // the agent never sees the lanes (E2E 2026-10-07: five sessions, zero feature documents).
258
+ const laneGate = isSoftware;
252
259
  // `sdd` is installed everywhere now, but its DECISION is about practice, not presence — so where
253
260
  // the chain is not practised the explicit `deferred` entry below must be the only one, or the
254
261
  // generic "installed → selected" mapping would shadow it and `reassess` would see nothing to watch.
@@ -269,9 +276,16 @@ export function buildOnboardingPlan(record, evidence) {
269
276
  // so the chain is always one request away; this decision is what `reassess` watches, and what the
270
277
  // floor reads. Collapsing the two would have deleted reassess's reason to exist as a side effect
271
278
  // of an installer change, which is not a decision an installer change gets to make.
272
- if (!practisesSdd) decisions.push(deferred('sdd', 'workflow', isSoftware
273
- ? 'The software scope is small, so specification overhead is not justified yet.'
274
- : 'SDD applies to substantial software work, which is not the declared project purpose.', sddTriggers, softwareTriggers));
279
+ // The reason says both halves, because the plan lists `.claude/skills/sdd` under managed paths
280
+ // and a bare "deferred" next to it read as a contradiction (E2E 2026-10-07). Only the wording
281
+ // changes: the decision stays the one deferred `workflow/sdd` entry that reassess watches.
282
+ const whyDeferred = isSoftware
283
+ ? 'the software scope is small, so specification overhead is not justified yet.'
284
+ : 'SDD applies to substantial software work, which is not the declared project purpose.';
285
+ const sddReason = skills.includes('sdd')
286
+ ? `The sdd skill is installed, so the chain is one request away; the practice is deferred: ${whyDeferred}`
287
+ : whyDeferred.charAt(0).toUpperCase() + whyDeferred.slice(1);
288
+ if (!practisesSdd) decisions.push(deferred('sdd', 'workflow', sddReason, sddTriggers, softwareTriggers));
275
289
  if (baseAgents) {
276
290
  for (const id of agents) decisions.push(selected(id, 'agent', 'The accepted substantial software workflow requires this implementation or review role.'));
277
291
  } else {
@@ -296,6 +310,7 @@ export function buildOnboardingPlan(record, evidence) {
296
310
  codeHooks: practisesSdd,
297
311
  gitmojiGuard,
298
312
  dangerGuard,
313
+ laneGate,
299
314
  memory: true,
300
315
  context7: false,
301
316
  };
@@ -0,0 +1,76 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, lstatSync, readdirSync, readFileSync, rmSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ // The `01-TOOLS/` scaffolding rsc writes on onboarding, in ONE place: what `ensureHarnessSkeleton`
7
+ // copies is exactly what `purge` may take back, and it may take back only a file still identical to
8
+ // what was copied. `01-TOOLS/` is the layer the user edits when adding tools, so anything that
9
+ // differs from the template is theirs and stays.
10
+
11
+ const LIB_DIR = dirname(fileURLToPath(import.meta.url));
12
+ export const TEMPLATE_SOURCE = join(LIB_DIR, '..', '..', 'skills', 'harness', 'assets', '_TEMPLATE');
13
+
14
+ // Los ficheros que la plantilla trae hoy. Se lee del asset en vez de codificar una lista, para que
15
+ // añadir un fichero a la plantilla no deje el suelo comprobando de menos en silencio.
16
+ //
17
+ // Un error aquí NO se convierte en lista vacía. Comérselo dejaba el suelo permanentemente
18
+ // insatisfacible, con un mensaje que ofrecía una acción que leía ese mismo asset ausente y que por
19
+ // tanto no podía arreglarlo nunca — y sin nombrar jamás la causa real, que es un paquete roto.
20
+ export function templateAssets() {
21
+ return readdirSync(TEMPLATE_SOURCE).sort();
22
+ }
23
+
24
+ // npm NUNCA empaqueta un fichero llamado `.gitignore`, así que el asset viaja sin punto y se copia
25
+ // con él. Sin esto, desde el paquete publicado se copiaban 4 de 5 ficheros y el que faltaba era el
26
+ // único que evita comitear el `.env` que el README —copiado por el propio instalador— manda crear.
27
+ const DOTTED = { gitignore: '.gitignore' };
28
+ export const targetName = (asset) => DOTTED[asset] ?? asset;
29
+
30
+ // La capa entera, no cada proveedor: el flujo documentado es copiar `_TEMPLATE/` a
31
+ // `01-TOOLS/<PROVEEDOR>/`, y depender de que cada copia se lleve su propia protección es depender
32
+ // de que nadie se salte un paso con credenciales de por medio.
33
+ export const LAYER_IGNORE = ['# Escrito por rsc: la capa de herramientas guarda credenciales.',
34
+ '*/.env', '*/.env.*', '!*/.env.example', '*/keys/', '*/out/', ''].join('\n');
35
+
36
+ const sameBytes = (a, b) => {
37
+ try { return readFileSync(a).equals(Buffer.isBuffer(b) ? b : readFileSync(b)); } catch { return false; }
38
+ };
39
+ const isPlainFile = (p) => { try { return lstatSync(p).isFile(); } catch { return false; } };
40
+
41
+ /**
42
+ * Take back the scaffolding rsc wrote, file by file, only where it is still byte-identical to what
43
+ * rsc wrote. Returns `{ removed, kept }` as paths relative to `cwd`, `kept` with a reason. Empty
44
+ * directories left behind are removed by the caller's directory sweep.
45
+ */
46
+ // Committed means the team has it: deleting it leaves the tree dirty and takes it from everybody on
47
+ // the next push (field test 3.0.8: purge deleted a 01-TOOLS/.gitignore that was already in history).
48
+ function tracked(cwd, rel) {
49
+ try { return Boolean(execFileSync('git', ['ls-files', '--', rel], { cwd, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim()); } catch { return false; }
50
+ }
51
+
52
+ export function removeUnmodifiedSkeleton(cwd, { dryRun = false } = {}) {
53
+ const removed = [];
54
+ const kept = [];
55
+ const committed = (rel) => tracked(cwd, rel) && (kept.push({ path: rel, reason: 'committed to git — the team has it; delete it in a commit if you mean to' }), true);
56
+ const templateDir = join(cwd, '01-TOOLS', '_TEMPLATE');
57
+ if (existsSync(templateDir) && !lstatSync(templateDir).isSymbolicLink()) {
58
+ for (const asset of templateAssets()) {
59
+ const rel = `01-TOOLS/_TEMPLATE/${targetName(asset)}`;
60
+ const file = join(cwd, rel);
61
+ if (!existsSync(file) || committed(rel)) continue;
62
+ if (isPlainFile(file) && sameBytes(file, join(TEMPLATE_SOURCE, asset))) {
63
+ if (!dryRun) rmSync(file, { force: true });
64
+ removed.push(rel);
65
+ } else kept.push({ path: rel, reason: 'changed since rsc wrote it — yours now' });
66
+ }
67
+ }
68
+ const layer = join(cwd, '01-TOOLS', '.gitignore');
69
+ if (existsSync(layer) && !committed('01-TOOLS/.gitignore')) {
70
+ if (isPlainFile(layer) && sameBytes(layer, Buffer.from(LAYER_IGNORE))) {
71
+ if (!dryRun) rmSync(layer, { force: true });
72
+ removed.push('01-TOOLS/.gitignore');
73
+ } else kept.push({ path: '01-TOOLS/.gitignore', reason: 'changed since rsc wrote it — yours now' });
74
+ }
75
+ return { removed, kept };
76
+ }