jhste-skills 0.1.0 → 0.1.1
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 +2 -0
- package/cli/install-actions/apply-plan.mjs +1 -0
- package/cli/install-actions/skills.mjs +29 -7
- package/cli/sync-core.mjs +1 -0
- package/docs/ACCEPTANCE_CHECK.md +1 -1
- package/docs/CLI.md +3 -2
- package/docs/CONFLICT_RESOLUTION.md +1 -1
- package/package.json +1 -1
- package/scripts/smoke/guard-and-hook-scenarios.mjs +5 -2
- package/scripts/smoke/helpers.mjs +4 -0
- package/scripts/smoke/install-scenarios.mjs +16 -2
package/README.md
CHANGED
|
@@ -252,3 +252,5 @@ See [`docs/ACCEPTANCE_CHECK.md`](docs/ACCEPTANCE_CHECK.md) for release acceptanc
|
|
|
252
252
|
- Run a red-team code review before calling non-trivial work complete.
|
|
253
253
|
|
|
254
254
|
Fast agents need guardrails. `jhste-skills` gives them a repo-respecting engineering workflow.
|
|
255
|
+
|
|
256
|
+
Installed skill directories are tracked with `.jhste-skills-manifest.json`. `--force` refreshes manifest-managed skill copies; overwriting unmanaged differing skill directories still requires the separate `--allow-unmanaged-skill-overwrite` flag after review. `sync` and `update` can also adopt additional known jhste skills into an already managed skills directory so older mixed installs can be reconciled without a manual overwrite flag.
|
|
@@ -15,6 +15,7 @@ export function applyPlan(plan) {
|
|
|
15
15
|
result.skillResults = installSkills(plan.skillsDir, {
|
|
16
16
|
force: plan.forceSkills ?? plan.force,
|
|
17
17
|
allowUnmanagedOverwrite: plan.allowUnmanagedSkillOverwrite,
|
|
18
|
+
adoptKnownSkills: Boolean(plan.adoptKnownSkills),
|
|
18
19
|
skillSet: plan.skillNames ?? plan.skillSet,
|
|
19
20
|
});
|
|
20
21
|
if (result.skillResults.some((item) => ['skipped-unmanaged-different', 'invalid-manifest'].includes(item.status))) {
|
|
@@ -68,7 +68,17 @@ function packageVersion() {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
function
|
|
71
|
+
function canAdoptKnownSkill({ manifest = null, adoptKnownSkills = false } = {}) {
|
|
72
|
+
return Boolean(adoptKnownSkills && manifest && !manifest.invalid);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function copyManagedSkill(source, destination, name, {
|
|
76
|
+
force = false,
|
|
77
|
+
allowUnmanagedOverwrite = false,
|
|
78
|
+
adoptKnownSkills = false,
|
|
79
|
+
manifest = null,
|
|
80
|
+
nextManifest,
|
|
81
|
+
} = {}) {
|
|
72
82
|
if (!fs.existsSync(source)) return { status: 'missing-source', source, destination };
|
|
73
83
|
const sourceHash = directoryDigest(source);
|
|
74
84
|
const destinationExists = fs.existsSync(destination);
|
|
@@ -91,7 +101,8 @@ function copyManagedSkill(source, destination, name, { force = false, allowUnman
|
|
|
91
101
|
return { status: 'unchanged', source, destination };
|
|
92
102
|
}
|
|
93
103
|
if (!force) return { status: 'skipped-existing-different', source, destination };
|
|
94
|
-
|
|
104
|
+
const adoptKnownSkill = !manifestOwnsDestination && canAdoptKnownSkill({ manifest, adoptKnownSkills });
|
|
105
|
+
if (!manifestOwnsDestination && !allowUnmanagedOverwrite && !adoptKnownSkill) {
|
|
95
106
|
return {
|
|
96
107
|
status: 'skipped-unmanaged-different',
|
|
97
108
|
source,
|
|
@@ -102,17 +113,20 @@ function copyManagedSkill(source, destination, name, { force = false, allowUnman
|
|
|
102
113
|
fs.rmSync(destination, { recursive: true, force: true });
|
|
103
114
|
fs.cpSync(source, destination, { recursive: true });
|
|
104
115
|
recordManaged();
|
|
105
|
-
return { status:
|
|
116
|
+
if (manifestOwnsDestination) return { status: 'overwritten-managed', source, destination };
|
|
117
|
+
if (adoptKnownSkill) return { status: 'adopted-managed', source, destination };
|
|
118
|
+
return { status: 'overwritten-unmanaged', source, destination };
|
|
106
119
|
}
|
|
107
120
|
|
|
108
|
-
function unmanagedSkillConflicts(selected, sourceRoot, skillsDir, currentManifest) {
|
|
121
|
+
function unmanagedSkillConflicts(selected, sourceRoot, skillsDir, currentManifest, { adoptKnownSkills = false } = {}) {
|
|
122
|
+
const canAdopt = canAdoptKnownSkill({ manifest: currentManifest, adoptKnownSkills });
|
|
109
123
|
const out = [];
|
|
110
124
|
for (const name of selected) {
|
|
111
125
|
const source = path.join(sourceRoot, name);
|
|
112
126
|
const destination = path.join(skillsDir, name);
|
|
113
127
|
if (!fs.existsSync(source) || !fs.existsSync(destination)) continue;
|
|
114
128
|
if (directoryDigest(source) === directoryDigest(destination)) continue;
|
|
115
|
-
if (!currentManifest?.skills?.[name]) {
|
|
129
|
+
if (!currentManifest?.skills?.[name] && !canAdopt) {
|
|
116
130
|
out.push({
|
|
117
131
|
status: 'skipped-unmanaged-different',
|
|
118
132
|
source,
|
|
@@ -124,7 +138,12 @@ function unmanagedSkillConflicts(selected, sourceRoot, skillsDir, currentManifes
|
|
|
124
138
|
return out;
|
|
125
139
|
}
|
|
126
140
|
|
|
127
|
-
export function installSkills(skillsDir, {
|
|
141
|
+
export function installSkills(skillsDir, {
|
|
142
|
+
force = false,
|
|
143
|
+
skillSet = 'core',
|
|
144
|
+
allowUnmanagedOverwrite = false,
|
|
145
|
+
adoptKnownSkills = false,
|
|
146
|
+
} = {}) {
|
|
128
147
|
const sourceRoot = path.join(KIT_ROOT, 'skills');
|
|
129
148
|
ensureDir(skillsDir);
|
|
130
149
|
const selected = Array.isArray(skillSet) ? skillSet : skillNamesForSet(skillSet);
|
|
@@ -135,11 +154,14 @@ export function installSkills(skillsDir, { force = false, skillSet = 'core', all
|
|
|
135
154
|
nextManifest.version = packageVersion() || String(nextManifest.version || '0.0.0');
|
|
136
155
|
nextManifest.updated_at = nowIso();
|
|
137
156
|
nextManifest.skills ||= {};
|
|
138
|
-
const conflicts = force && !allowUnmanagedOverwrite
|
|
157
|
+
const conflicts = force && !allowUnmanagedOverwrite
|
|
158
|
+
? unmanagedSkillConflicts(selected, sourceRoot, skillsDir, currentManifest, { adoptKnownSkills })
|
|
159
|
+
: [];
|
|
139
160
|
if (conflicts.length) return conflicts;
|
|
140
161
|
const results = selected.map((name) => copyManagedSkill(path.join(sourceRoot, name), path.join(skillsDir, name), name, {
|
|
141
162
|
force,
|
|
142
163
|
allowUnmanagedOverwrite,
|
|
164
|
+
adoptKnownSkills,
|
|
143
165
|
manifest: currentManifest,
|
|
144
166
|
nextManifest,
|
|
145
167
|
}));
|
package/cli/sync-core.mjs
CHANGED
|
@@ -159,6 +159,7 @@ function buildSyncPlan(options, command) {
|
|
|
159
159
|
mode: 'sync',
|
|
160
160
|
yes: options.yes,
|
|
161
161
|
force: options.force,
|
|
162
|
+
adoptKnownSkills: true,
|
|
162
163
|
allowUnmanagedSkillOverwrite: options.allowUnmanagedSkillOverwrite,
|
|
163
164
|
forceSkills: true,
|
|
164
165
|
installMissing: false,
|
package/docs/ACCEPTANCE_CHECK.md
CHANGED
|
@@ -34,7 +34,7 @@ Rule modes are documented in `docs/RULES.md`, example profile defaults to adviso
|
|
|
34
34
|
|
|
35
35
|
## Conflict handling
|
|
36
36
|
|
|
37
|
-
`docs/CONFLICT_RESOLUTION.md` and installer behavior preserve existing repo instructions, skip existing differing profiles/skills by default, make bridge insertion marker-managed and idempotent,
|
|
37
|
+
`docs/CONFLICT_RESOLUTION.md` and installer behavior preserve existing repo instructions, skip existing differing profiles/skills by default, make bridge insertion marker-managed and idempotent, refuse to overwrite non-managed hooks even in Full mode or with `--force`, refuse unmanaged skill-directory overwrites without `--allow-unmanaged-skill-overwrite`, and let `sync`/`update` adopt additional known jhste skills into an already managed skills directory.
|
|
38
38
|
|
|
39
39
|
## Verification commands
|
|
40
40
|
|
package/docs/CLI.md
CHANGED
|
@@ -95,7 +95,8 @@ Stable contract:
|
|
|
95
95
|
- only refreshes repo outputs that already look managed by jhste-skills (`.jhste/profile.yaml`, managed bridge markers, or managed hooks);
|
|
96
96
|
- does not bootstrap unmanaged repositories; use `install` or `connect` for first-time setup;
|
|
97
97
|
- preserves non-managed hooks and does not touch source files, CI, `package.json`, or lockfiles;
|
|
98
|
-
- `--force` still applies only to repo-managed outputs such as overwriting an existing managed profile; unmanaged differing skill directories require `--allow-unmanaged-skill-overwrite
|
|
98
|
+
- `--force` still applies only to repo-managed outputs such as overwriting an existing managed profile; unmanaged differing skill directories require `--allow-unmanaged-skill-overwrite`;
|
|
99
|
+
- `sync`/`update` may adopt additional known jhste skills into an already managed skills directory and refresh them without the extra overwrite flag.
|
|
99
100
|
|
|
100
101
|
## `update`
|
|
101
102
|
|
|
@@ -144,7 +145,7 @@ The JSON output starts with:
|
|
|
144
145
|
"schema_version": 1,
|
|
145
146
|
"summary": { "error": 0, "warning": 0, "info": 0, "baseline_matched": 0, "suppressed": 0, "failures": 0 },
|
|
146
147
|
"meta": {
|
|
147
|
-
"tool_version": "0.1.
|
|
148
|
+
"tool_version": "0.1.1",
|
|
148
149
|
"scope": "changed",
|
|
149
150
|
"files_considered": 0,
|
|
150
151
|
"files_scanned": 0,
|
|
@@ -18,7 +18,7 @@ If `.jhste/profile.yaml` exists, default install keeps it. Overwrite requires `-
|
|
|
18
18
|
|
|
19
19
|
## Existing skills
|
|
20
20
|
|
|
21
|
-
Installed skill directories are tracked in `.jhste-skills-manifest.json` inside the skills directory. If a target skill directory already exists and differs, default install skips it; `--force` can refresh manifest-managed copies, but unmanaged differing directories are refused unless `--allow-unmanaged-skill-overwrite` is also explicit. The manifest stores skill digests, not absolute local paths.
|
|
21
|
+
Installed skill directories are tracked in `.jhste-skills-manifest.json` inside the skills directory. If a target skill directory already exists and differs, default install skips it; `--force` can refresh manifest-managed copies, but unmanaged differing directories are refused unless `--allow-unmanaged-skill-overwrite` is also explicit. During `sync`/`update`, an already managed skills directory may adopt additional known jhste skills into the manifest and refresh them without the extra overwrite flag. The manifest stores skill digests, not absolute local paths.
|
|
22
22
|
|
|
23
23
|
## Bridge block
|
|
24
24
|
|
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import {
|
|
4
4
|
fail,
|
|
5
5
|
hashFile,
|
|
6
|
+
packageVersion,
|
|
6
7
|
parseJsonOutput,
|
|
7
8
|
run,
|
|
8
9
|
runAny,
|
|
@@ -105,10 +106,11 @@ function assertDeepScanReport(report) {
|
|
|
105
106
|
|
|
106
107
|
function runGuardContracts(ctx) {
|
|
107
108
|
const { root, repo, report, tmp } = ctx;
|
|
109
|
+
const version = packageVersion(root);
|
|
108
110
|
const guardJson = run(process.execPath, [path.join(root, 'cli/guard.mjs'), '--repo', repo, '--scope', 'all', '--format', 'json', '--fail-on', 'none'], { cwd: repo }).stdout;
|
|
109
111
|
const guardResult = parseJsonOutput(guardJson, 'guard result');
|
|
110
112
|
if (guardResult.schema_version !== 1) fail('guard JSON schema_version missing');
|
|
111
|
-
if (guardResult.meta?.tool_version !==
|
|
113
|
+
if (guardResult.meta?.tool_version !== version) fail('guard JSON meta tool_version missing');
|
|
112
114
|
if (typeof guardResult.meta?.files_considered !== 'number') fail('guard JSON meta files_considered missing');
|
|
113
115
|
for (const [ruleId, message] of [
|
|
114
116
|
['silent.catch.empty', 'guard did not report empty catch'],
|
|
@@ -154,6 +156,7 @@ function assertGuardFailureModes({ root, repo, tmp }) {
|
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
function runProfileCommandAndHookContracts({ root, repo, profilePath, packageHashBefore }) {
|
|
159
|
+
const version = packageVersion(root);
|
|
157
160
|
const fakeOpenAiKey = 'sk-' + 'A'.repeat(24);
|
|
158
161
|
const fakeGithubToken = 'gh' + 'p_' + 'B'.repeat(36);
|
|
159
162
|
const fakeGenericSecret = 'C'.repeat(16);
|
|
@@ -191,7 +194,7 @@ function runProfileCommandAndHookContracts({ root, repo, profilePath, packageHas
|
|
|
191
194
|
const preCommit = path.join(repo, '.git', 'hooks', 'pre-commit');
|
|
192
195
|
const preCommitText = fs.readFileSync(preCommit, 'utf8');
|
|
193
196
|
if (!preCommitText.includes('jhste-skills managed hook start')) fail('managed pre-commit hook missing marker');
|
|
194
|
-
if (!preCommitText.includes(
|
|
197
|
+
if (!preCommitText.includes(`# jhste-skills version=${version}`)) fail('managed pre-commit hook missing version comment');
|
|
195
198
|
if (preCommitText.indexOf("node '") > preCommitText.indexOf('command -v jhste-skills')) fail('managed hook should prefer local CLI before global fallback');
|
|
196
199
|
const fakeBin = path.join(path.dirname(preCommit), 'fake-bin');
|
|
197
200
|
fs.mkdirSync(fakeBin);
|
|
@@ -36,6 +36,10 @@ export function hashFile(file) {
|
|
|
36
36
|
return fs.existsSync(file) ? crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') : null;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export function packageVersion(root) {
|
|
40
|
+
return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
|
|
41
|
+
}
|
|
42
|
+
|
|
39
43
|
export function skillDirs(dir) {
|
|
40
44
|
return fs.readdirSync(dir, { withFileTypes: true })
|
|
41
45
|
.filter((entry) => entry.isDirectory())
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
assertNoInstallSideEffects,
|
|
5
5
|
fail,
|
|
6
6
|
hashFile,
|
|
7
|
+
packageVersion,
|
|
7
8
|
run,
|
|
8
9
|
runAny,
|
|
9
10
|
skillDirs,
|
|
@@ -77,6 +78,7 @@ function runRefusalScenarios({ root, tmp }) {
|
|
|
77
78
|
|
|
78
79
|
function runDefaultInstall(ctx) {
|
|
79
80
|
const { root, repo, skillsDir } = ctx;
|
|
81
|
+
const version = packageVersion(root);
|
|
80
82
|
ctx.packageHashBefore = hashFile(path.join(repo, 'package.json'));
|
|
81
83
|
ctx.lockHashBefore = hashFile(path.join(repo, 'package-lock.json'));
|
|
82
84
|
const started = Date.now();
|
|
@@ -98,7 +100,7 @@ function runDefaultInstall(ctx) {
|
|
|
98
100
|
const defaultPreCommit = path.join(repo, '.git', 'hooks', 'pre-commit');
|
|
99
101
|
if (!fs.existsSync(defaultPreCommit)) fail('install did not create default advisory pre-commit hook');
|
|
100
102
|
if (!fs.readFileSync(defaultPreCommit, 'utf8').includes('mode=advisory')) fail('default pre-commit hook is not advisory');
|
|
101
|
-
if (!fs.readFileSync(defaultPreCommit, 'utf8').includes(
|
|
103
|
+
if (!fs.readFileSync(defaultPreCommit, 'utf8').includes(`# jhste-skills version=${version}`)) fail('default pre-commit hook missing version comment');
|
|
102
104
|
if (!fs.existsSync(path.join(skillsDir, 'jhste-red-team-review', 'SKILL.md'))) fail('install did not copy jhste-red-team-review skill');
|
|
103
105
|
if (!fs.existsSync(path.join(skillsDir, '.jhste-skills-manifest.json'))) fail('install did not write skills manifest');
|
|
104
106
|
const manifest = JSON.parse(fs.readFileSync(path.join(skillsDir, '.jhste-skills-manifest.json'), 'utf8'));
|
|
@@ -109,12 +111,18 @@ function runDefaultInstall(ctx) {
|
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
function runUpdateScenarios({ root, repo, skillsDir }) {
|
|
114
|
+
const version = packageVersion(root);
|
|
112
115
|
const skillPath = path.join(skillsDir, 'jhste-code-quality', 'SKILL.md');
|
|
113
116
|
const sourceSkillPath = path.join(root, 'skills', 'jhste-code-quality', 'SKILL.md');
|
|
117
|
+
const adoptedSkillName = 'triage';
|
|
118
|
+
const adoptedSkillPath = path.join(skillsDir, adoptedSkillName, 'SKILL.md');
|
|
119
|
+
const adoptedSourceSkillPath = path.join(root, 'skills', adoptedSkillName, 'SKILL.md');
|
|
114
120
|
const agentsPath = path.join(repo, 'AGENTS.md');
|
|
115
121
|
const preCommitPath = path.join(repo, '.git', 'hooks', 'pre-commit');
|
|
116
122
|
|
|
117
123
|
fs.writeFileSync(skillPath, '# stale local copy\n');
|
|
124
|
+
fs.mkdirSync(path.dirname(adoptedSkillPath), { recursive: true });
|
|
125
|
+
fs.writeFileSync(adoptedSkillPath, '# unmanaged but known skill copy\n');
|
|
118
126
|
|
|
119
127
|
const existingAgents = fs.readFileSync(agentsPath, 'utf8');
|
|
120
128
|
const staleAgents = existingAgents.replace(
|
|
@@ -136,6 +144,9 @@ run_jhste_skills guard --scope staged --format text --fail-on warning
|
|
|
136
144
|
if (fs.readFileSync(skillPath, 'utf8') !== fs.readFileSync(sourceSkillPath, 'utf8')) {
|
|
137
145
|
fail('update did not refresh an installed skill back to the current source version');
|
|
138
146
|
}
|
|
147
|
+
if (fs.readFileSync(adoptedSkillPath, 'utf8') !== fs.readFileSync(adoptedSourceSkillPath, 'utf8')) {
|
|
148
|
+
fail('update did not adopt and refresh a known jhste skill into an existing managed installation');
|
|
149
|
+
}
|
|
139
150
|
|
|
140
151
|
const updatedAgents = fs.readFileSync(agentsPath, 'utf8');
|
|
141
152
|
if (updatedAgents.includes('Old bridge text that should be replaced.')) {
|
|
@@ -148,9 +159,12 @@ run_jhste_skills guard --scope staged --format text --fail-on warning
|
|
|
148
159
|
const updatedPreCommit = fs.readFileSync(preCommitPath, 'utf8');
|
|
149
160
|
if (!updatedPreCommit.includes('mode=blocking')) fail('update did not preserve managed hook mode');
|
|
150
161
|
if (!updatedPreCommit.includes('--fail-on warning')) fail('update did not preserve managed hook fail-on behavior');
|
|
151
|
-
if (!updatedPreCommit.includes(
|
|
162
|
+
if (!updatedPreCommit.includes(`# jhste-skills version=${version}`)) fail('update did not refresh hook version comment');
|
|
152
163
|
if (updatedPreCommit.includes('stale hook')) fail('update did not replace stale managed hook content');
|
|
153
164
|
|
|
165
|
+
const managedManifest = JSON.parse(fs.readFileSync(path.join(skillsDir, '.jhste-skills-manifest.json'), 'utf8'));
|
|
166
|
+
if (!managedManifest.skills?.[adoptedSkillName]?.digest) fail('update did not record adopted known skill in manifest');
|
|
167
|
+
|
|
154
168
|
const unmanagedSkills = path.join(path.dirname(skillsDir), 'unmanaged-skills');
|
|
155
169
|
fs.mkdirSync(path.join(unmanagedSkills, 'jhste-code-quality'), { recursive: true });
|
|
156
170
|
fs.writeFileSync(path.join(unmanagedSkills, 'jhste-code-quality', 'SKILL.md'), '# unmanaged local copy\n');
|