@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.
- package/README.md +61 -17
- package/manifest.json +1 -1
- package/package.json +3 -1
- package/scripts/audit.js +11 -0
- package/scripts/doctor.js +63 -11
- package/scripts/install-apply.js +224 -18
- package/scripts/lib/onboarding-apply.js +27 -24
- package/scripts/lib/onboarding.js +19 -4
- package/scripts/lib/tools-skeleton.js +76 -0
- package/scripts/rsc.js +93 -13
- package/skills/suggest/SKILL.md +5 -4
- package/targets/branch-guard.mjs +94 -26
- package/targets/claude.js +36 -6
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/danger-guard.mjs +80 -14
- package/targets/deepseek-bridge.js +86 -0
- package/targets/deepseek-bridge.mjs +90 -0
- package/targets/ftd-nudge.mjs +137 -0
- package/targets/gitmoji-guard.mjs +14 -1
- package/targets/index.js +4 -0
- package/targets/knowledge-sync.mjs +203 -45
- package/targets/knowledge-wiring.js +2 -0
- package/targets/memory.js +7 -0
- package/targets/opt-outs.js +1 -0
- package/targets/sello.mjs +9 -2
- package/targets/session-memory-adapter.mjs +28 -4
- package/targets/session-memory-core.mjs +211 -36
- package/targets/session-start.mjs +40 -5
- package/targets/shell-unwrap.mjs +145 -0
- package/targets/ship-guard.mjs +40 -9
- package/targets/team-safe-start.mjs +76 -10
- package/targets/update-wiring.js +2 -1
- package/targets/worktree-reaper.mjs +109 -3
package/scripts/install-apply.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
707
|
-
const
|
|
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.
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
354
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
+
}
|