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 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 copyManagedSkill(source, destination, name, { force = false, allowUnmanagedOverwrite = false, manifest = null, nextManifest } = {}) {
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
- if (!manifestOwnsDestination && !allowUnmanagedOverwrite) {
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: manifestOwnsDestination ? 'overwritten-managed' : 'overwritten-unmanaged', source, destination };
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, { force = false, skillSet = 'core', allowUnmanagedOverwrite = false } = {}) {
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 ? unmanagedSkillConflicts(selected, sourceRoot, skillsDir, currentManifest) : [];
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,
@@ -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, and refuse to overwrite non-managed hooks even in Full mode or with `--force`, and refuse unmanaged skill-directory overwrites without `--allow-unmanaged-skill-overwrite`.
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.0",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jhste-skills",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Installable engineering guardrails and workflow skills for AI coding agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 !== '0.1.0') fail('guard JSON meta tool_version missing');
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('# jhste-skills version=0.1.0')) fail('managed pre-commit hook missing version comment');
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('# jhste-skills version=0.1.0')) fail('default pre-commit hook missing version comment');
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('# jhste-skills version=0.1.0')) fail('update did not refresh hook version comment');
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');