universal-dev-standards 5.14.0 → 5.15.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.
Files changed (71) hide show
  1. package/bin/uds.js +2 -0
  2. package/bundled/ai/standards/ai-instruction-standards.ai.yaml +190 -3
  3. package/bundled/ai/standards/knowledge-graph-memory.ai.yaml +83 -0
  4. package/bundled/core/ai-instruction-standards.md +136 -11
  5. package/bundled/core/knowledge-graph-memory.md +119 -0
  6. package/bundled/locales/COVERAGE.md +226 -0
  7. package/bundled/locales/zh-CN/CHANGELOG.md +32 -3
  8. package/bundled/locales/zh-CN/README.md +1 -1
  9. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  10. package/bundled/locales/zh-CN/core/ai-instruction-standards.md +111 -5
  11. package/bundled/locales/zh-TW/CHANGELOG.md +36 -3
  12. package/bundled/locales/zh-TW/README.md +1 -1
  13. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  14. package/bundled/locales/zh-TW/core/ai-instruction-standards.md +130 -5
  15. package/bundled/locales/zh-TW/core/knowledge-graph-memory.md +127 -0
  16. package/bundled/locales/zh-TW/core/self-review-protocol.md +9 -1
  17. package/bundled/locales/zh-TW/skills/ac-coverage/SKILL.md +192 -0
  18. package/bundled/locales/zh-TW/skills/ai-collaboration-standards/SKILL.md +5 -1
  19. package/bundled/locales/zh-TW/skills/deploy-assistant/SKILL.md +187 -0
  20. package/bundled/locales/zh-TW/skills/dev-methodology/SKILL.md +108 -0
  21. package/bundled/locales/zh-TW/skills/journey-test-assistant/SKILL.md +222 -0
  22. package/bundled/locales/zh-TW/skills/knowledge-graph/SKILL.md +56 -0
  23. package/bundled/locales/zh-TW/skills/orchestrate/SKILL.md +172 -0
  24. package/bundled/locales/zh-TW/skills/plan/SKILL.md +239 -0
  25. package/bundled/locales/zh-TW/skills/project-structure-guide/SKILL.md +5 -1
  26. package/bundled/locales/zh-TW/skills/push/SKILL.md +241 -0
  27. package/bundled/locales/zh-TW/skills/skill-builder/SKILL.md +165 -0
  28. package/bundled/locales/zh-TW/skills/spec-derivation/SKILL.md +83 -0
  29. package/bundled/locales/zh-TW/skills/sweep/SKILL.md +149 -0
  30. package/bundled/skills/adr-assistant/SKILL.md +1 -1
  31. package/bundled/skills/ai-collaboration-standards/SKILL.md +1 -1
  32. package/bundled/skills/ai-friendly-architecture/SKILL.md +1 -1
  33. package/bundled/skills/ai-instruction-standards/SKILL.md +1 -1
  34. package/bundled/skills/api-design-assistant/SKILL.md +1 -1
  35. package/bundled/skills/audit-assistant/SKILL.md +1 -1
  36. package/bundled/skills/ci-cd-assistant/SKILL.md +1 -1
  37. package/bundled/skills/contract-test-assistant/SKILL.md +1 -1
  38. package/bundled/skills/database-assistant/SKILL.md +1 -1
  39. package/bundled/skills/deploy-assistant/SKILL.md +1 -1
  40. package/bundled/skills/documentation-guide/SKILL.md +1 -1
  41. package/bundled/skills/error-code-guide/SKILL.md +1 -1
  42. package/bundled/skills/git-workflow-guide/SKILL.md +1 -1
  43. package/bundled/skills/incident-response-assistant/SKILL.md +1 -1
  44. package/bundled/skills/journey-test-assistant/SKILL.md +1 -1
  45. package/bundled/skills/knowledge-graph/SKILL.md +58 -0
  46. package/bundled/skills/knowledge-graph/guide.md +69 -0
  47. package/bundled/skills/logging-guide/SKILL.md +1 -1
  48. package/bundled/skills/observability-assistant/SKILL.md +1 -1
  49. package/bundled/skills/orchestrate/SKILL.md +1 -1
  50. package/bundled/skills/plan/SKILL.md +1 -1
  51. package/bundled/skills/pr-automation-assistant/SKILL.md +1 -1
  52. package/bundled/skills/project-structure-guide/SKILL.md +1 -1
  53. package/bundled/skills/push/SKILL.md +1 -1
  54. package/bundled/skills/retrospective-assistant/SKILL.md +1 -1
  55. package/bundled/skills/reverse-engineer/SKILL.md +1 -1
  56. package/bundled/skills/runbook-assistant/SKILL.md +1 -1
  57. package/bundled/skills/security-assistant/SKILL.md +1 -1
  58. package/bundled/skills/security-scan-assistant/SKILL.md +1 -1
  59. package/bundled/skills/slo-assistant/SKILL.md +1 -1
  60. package/bundled/skills/sweep/SKILL.md +1 -1
  61. package/bundled/skills/testing-guide/SKILL.md +1 -1
  62. package/package.json +1 -1
  63. package/src/commands/check.js +71 -0
  64. package/src/commands/init.js +8 -1
  65. package/src/commands/update.js +49 -14
  66. package/src/i18n/messages.js +32 -5
  67. package/src/installers/skills-installer.js +49 -0
  68. package/src/lint/i18n.js +338 -0
  69. package/src/utils/config-manager.js +39 -0
  70. package/src/utils/skills-installer.js +39 -7
  71. package/standards-registry.json +16 -4
@@ -0,0 +1,338 @@
1
+ /**
2
+ * i18n Lint Rules (XSPEC-239 §Req-5 + Chimera Prevention)
3
+ *
4
+ * Detects layered-language-strategy violations in canonical and locale files.
5
+ *
6
+ * Rules:
7
+ * canonical:description-must-be-ascii (error)
8
+ * locale:description-must-match-language (error)
9
+ * locale:must-have-source-frontmatter (error)
10
+ * canonical:l3-language-consistency (warn)
11
+ * translation-drift-warn (warn)
12
+ *
13
+ * `adopter-must-match-installed-locale` is deferred (needs project context,
14
+ * not source-tree level).
15
+ *
16
+ * @module lint/i18n
17
+ */
18
+
19
+ import { existsSync, readFileSync, readdirSync } from 'fs';
20
+ import { join, dirname, basename, resolve } from 'path';
21
+
22
+ // CJK Unified Ideographs ranges (excluding fullwidth punctuation, but
23
+ // including ranges Ext-A/B used by zh-TW/zh-CN). Hangul, Hiragana,
24
+ // Katakana included to also flag accidentally-mixed CJK content.
25
+ const CJK_REGEX = /[㐀-䶿一-鿿豈-﫿぀-ゟ゠-ヿ가-힯]/;
26
+
27
+ // ASCII-only regex (allow printable ASCII + space + common punctuation).
28
+ // Anything outside extended-ASCII counts as non-ASCII for description purposes.
29
+ // Control chars \x09 (tab), \x0A (LF), \x0D (CR) are intentionally allowed.
30
+ // eslint-disable-next-line no-control-regex
31
+ const NON_ASCII_REGEX = /[^\x09\x0A\x0D\x20-\x7E]/;
32
+
33
+ /**
34
+ * Parse a minimal YAML front matter from markdown content.
35
+ * Returns { fm: {key→value}, fmEndLine: number, body: string }
36
+ * If there is no front matter, returns fm=null, fmEndLine=0, body=full content.
37
+ */
38
+ function parseFrontmatter(content) {
39
+ const lines = content.split('\n');
40
+ if (lines[0] !== '---') {
41
+ return { fm: null, fmEndLine: 0, body: content };
42
+ }
43
+ const fm = {};
44
+ let fmEndLine = -1;
45
+ for (let i = 1; i < lines.length; i++) {
46
+ if (lines[i] === '---') {
47
+ fmEndLine = i;
48
+ break;
49
+ }
50
+ const m = lines[i].match(/^([a-zA-Z_][\w-]*):\s*(.*)$/);
51
+ if (m) {
52
+ let value = m[2].trim();
53
+ // Multi-line block scalar (| or >) — collect lines until indentation breaks
54
+ if (value === '|' || value === '>') {
55
+ const blockLines = [];
56
+ for (let j = i + 1; j < lines.length; j++) {
57
+ if (lines[j] === '---') break;
58
+ if (lines[j].startsWith(' ') || lines[j] === '') {
59
+ blockLines.push(lines[j].replace(/^ {0,2}/, ''));
60
+ } else {
61
+ break;
62
+ }
63
+ }
64
+ value = blockLines.join('\n').trim();
65
+ } else if ((value.startsWith('"') && value.endsWith('"')) ||
66
+ (value.startsWith("'") && value.endsWith("'"))) {
67
+ value = value.slice(1, -1);
68
+ }
69
+ fm[m[1]] = { value, line: i + 1 };
70
+ }
71
+ }
72
+ if (fmEndLine === -1) {
73
+ return { fm: null, fmEndLine: 0, body: content };
74
+ }
75
+ const body = lines.slice(fmEndLine + 1).join('\n');
76
+ return { fm, fmEndLine, body };
77
+ }
78
+
79
+ function semverParts(v) {
80
+ if (!v) return null;
81
+ const stripped = String(v).split('-')[0];
82
+ const parts = stripped.split('.').map(n => parseInt(n, 10));
83
+ if (parts.some(Number.isNaN)) return null;
84
+ return parts;
85
+ }
86
+
87
+ function isMajorOrMinorGap(translation, source, maxMinorGap = 2) {
88
+ const t = semverParts(translation);
89
+ const s = semverParts(source);
90
+ if (!t || !s) return null;
91
+ if ((t[0] || 0) !== (s[0] || 0)) return { kind: 'major' };
92
+ const gap = (s[1] || 0) - (t[1] || 0);
93
+ if (gap > maxMinorGap) return { kind: 'minor', gap };
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Look up the source markdown's version from its first 30 lines.
99
+ * Supports YAML frontmatter `version:` plus `**Version**: x.y.z` patterns.
100
+ */
101
+ function readSourceVersion(filePath) {
102
+ if (!existsSync(filePath)) return null;
103
+ const head = readFileSync(filePath, 'utf-8').split('\n').slice(0, 30);
104
+ for (const line of head) {
105
+ let m = line.match(/^\s*version:\s*(\S+)/i);
106
+ if (m) return m[1].trim();
107
+ m = line.match(/^\s*\*\*Version\*\*:\s*([\d][^|\s]*)/i);
108
+ if (m) return m[1].trim();
109
+ m = line.match(/^\s*>\s*Version:\s*([\d][^|\s]*)/i);
110
+ if (m) return m[1].trim();
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Lint a canonical SKILL.md or core/*.md file for English-only frontmatter
117
+ * and L3 language consistency.
118
+ *
119
+ * @param {string} skillMdPath - Absolute path to canonical file
120
+ * @returns {Array<{rule, severity, line, message, file}>}
121
+ */
122
+ export function lintCanonical(skillMdPath) {
123
+ const findings = [];
124
+ if (!existsSync(skillMdPath)) return findings;
125
+ const content = readFileSync(skillMdPath, 'utf-8');
126
+ const { fm, body } = parseFrontmatter(content);
127
+
128
+ // Rule: canonical:description-must-be-ascii
129
+ if (fm && fm.description) {
130
+ if (NON_ASCII_REGEX.test(fm.description.value)) {
131
+ findings.push({
132
+ rule: 'canonical:description-must-be-ascii',
133
+ severity: 'error',
134
+ line: fm.description.line,
135
+ file: skillMdPath,
136
+ message: 'Canonical `description` must be ASCII-only (English). Found non-ASCII characters; move translation to locale variant.'
137
+ });
138
+ }
139
+ }
140
+
141
+ // Rule: canonical:l3-language-consistency
142
+ // Heuristic: scan code-block-style output templates (```...```) for
143
+ // non-English example response text. Only fire when description is ASCII
144
+ // (otherwise the description-must-be-ascii error already covers it).
145
+ if (fm && fm.description && !NON_ASCII_REGEX.test(fm.description.value)) {
146
+ const fenceRegex = /```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g;
147
+ let match;
148
+ while ((match = fenceRegex.exec(body)) !== null) {
149
+ const lang = match[1];
150
+ const block = match[2];
151
+ // Skip code fences for known programming languages — those legitimately
152
+ // contain non-English string literals.
153
+ const skipLangs = new Set(['', 'json', 'yaml', 'yml', 'toml', 'ini',
154
+ 'sh', 'bash', 'zsh', 'powershell',
155
+ 'js', 'ts', 'tsx', 'jsx', 'javascript', 'typescript',
156
+ 'py', 'python', 'rb', 'ruby', 'go', 'rust', 'java', 'c', 'cpp',
157
+ 'html', 'css', 'sql', 'diff', 'patch']);
158
+ if (skipLangs.has(lang.toLowerCase())) continue;
159
+
160
+ // Likely an output template / example response (e.g., ```markdown,
161
+ // ```text, ```output, ```response). Flag if it contains CJK.
162
+ if (CJK_REGEX.test(block)) {
163
+ // Compute approx. line number of the fence start
164
+ const lineNum = content.substring(0, match.index).split('\n').length;
165
+ findings.push({
166
+ rule: 'canonical:l3-language-consistency',
167
+ severity: 'warn',
168
+ line: lineNum,
169
+ file: skillMdPath,
170
+ message: `Canonical body contains a non-English example response inside a non-code fenced block (lang=\`${lang || 'plain'}\`). Move translated examples to the locale variant.`
171
+ });
172
+ // Only report first occurrence per file to keep output readable
173
+ break;
174
+ }
175
+ }
176
+ }
177
+
178
+ return findings;
179
+ }
180
+
181
+ /**
182
+ * Lint a locale variant (e.g. locales/zh-TW/skills/foo/SKILL.md or
183
+ * locales/zh-CN/core/some-standard.md).
184
+ *
185
+ * @param {string} skillMdPath - Absolute path to locale variant file
186
+ * @param {string} locale - Locale code (e.g. 'zh-TW', 'zh-CN')
187
+ * @returns {Array<{rule, severity, line, message, file}>}
188
+ */
189
+ export function lintLocale(skillMdPath, locale) {
190
+ const findings = [];
191
+ if (!existsSync(skillMdPath)) return findings;
192
+ const content = readFileSync(skillMdPath, 'utf-8');
193
+ const { fm } = parseFrontmatter(content);
194
+
195
+ // Rule: locale:must-have-source-frontmatter
196
+ if (!fm) {
197
+ findings.push({
198
+ rule: 'locale:must-have-source-frontmatter',
199
+ severity: 'error',
200
+ line: 1,
201
+ file: skillMdPath,
202
+ message: 'Locale variant is missing YAML front matter (must include `source:`, `source_version:`, `translation_version:`).'
203
+ });
204
+ return findings;
205
+ }
206
+
207
+ const required = ['source', 'source_version', 'translation_version'];
208
+ const missing = required.filter(k => !fm[k]);
209
+ if (missing.length > 0) {
210
+ findings.push({
211
+ rule: 'locale:must-have-source-frontmatter',
212
+ severity: 'error',
213
+ line: 1,
214
+ file: skillMdPath,
215
+ message: `Locale variant is missing required frontmatter field(s): ${missing.join(', ')}.`
216
+ });
217
+ }
218
+
219
+ // Rule: locale:description-must-match-language
220
+ // For zh-TW / zh-CN, the description (if present) must contain CJK.
221
+ if (fm.description) {
222
+ const expectsCjk = locale === 'zh-TW' || locale === 'zh-CN' ||
223
+ locale.startsWith('zh-') || locale === 'ja-JP' ||
224
+ locale === 'ko-KR';
225
+ if (expectsCjk && !CJK_REGEX.test(fm.description.value)) {
226
+ findings.push({
227
+ rule: 'locale:description-must-match-language',
228
+ severity: 'error',
229
+ line: fm.description.line,
230
+ file: skillMdPath,
231
+ message: `Locale \`${locale}\` variant has ASCII-only \`description\`; locale variants must contain ${locale}-script characters (likely chimera).`
232
+ });
233
+ }
234
+ }
235
+
236
+ // Rule: translation-drift-warn
237
+ if (fm.source && fm.translation_version) {
238
+ // Resolve source path relative to this file's directory
239
+ const sourceRel = fm.source.value;
240
+ const sourcePath = resolve(dirname(skillMdPath), sourceRel);
241
+ const actualSourceVersion = readSourceVersion(sourcePath);
242
+ if (actualSourceVersion) {
243
+ const gap = isMajorOrMinorGap(fm.translation_version.value, actualSourceVersion);
244
+ if (gap) {
245
+ const detail = gap.kind === 'major'
246
+ ? 'major-version gap'
247
+ : `${gap.gap} minor versions behind`;
248
+ findings.push({
249
+ rule: 'translation-drift-warn',
250
+ severity: 'warn',
251
+ line: fm.translation_version.line,
252
+ file: skillMdPath,
253
+ message: `translation_version \`${fm.translation_version.value}\` lags source_version \`${actualSourceVersion}\` (${detail}). Re-sync recommended.`
254
+ });
255
+ }
256
+ }
257
+ }
258
+
259
+ return findings;
260
+ }
261
+
262
+ /**
263
+ * Batch-lint a UDS project tree. Scans canonical `skills/` and `core/`,
264
+ * plus `locales/{locale}/skills/` and `locales/{locale}/core/`.
265
+ *
266
+ * @param {object} opts
267
+ * @param {string} opts.projectPath - Project (or UDS) root path
268
+ * @param {string[]} [opts.locales] - Locales to lint (defaults to discovering
269
+ * every subdirectory of `locales/`)
270
+ * @returns {Array<{rule, severity, line, message, file}>}
271
+ */
272
+ export function lintAll({ projectPath, locales }) {
273
+ const findings = [];
274
+
275
+ const skillsDir = join(projectPath, 'skills');
276
+ const coreDir = join(projectPath, 'core');
277
+ const localesDir = join(projectPath, 'locales');
278
+
279
+ // --- Canonical skills ---
280
+ if (existsSync(skillsDir)) {
281
+ for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
282
+ if (!entry.isDirectory()) continue;
283
+ const skillFile = join(skillsDir, entry.name, 'SKILL.md');
284
+ if (existsSync(skillFile)) {
285
+ findings.push(...lintCanonical(skillFile));
286
+ }
287
+ }
288
+ }
289
+
290
+ // --- Canonical core/*.md ---
291
+ if (existsSync(coreDir)) {
292
+ for (const entry of readdirSync(coreDir, { withFileTypes: true })) {
293
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
294
+ findings.push(...lintCanonical(join(coreDir, entry.name)));
295
+ }
296
+ }
297
+
298
+ // --- Locale variants ---
299
+ if (existsSync(localesDir)) {
300
+ const candidateLocales = locales || readdirSync(localesDir, { withFileTypes: true })
301
+ .filter(d => d.isDirectory())
302
+ .map(d => d.name)
303
+ .filter(name => /^[a-z]{2}(-[A-Z]{2})?$/.test(name));
304
+
305
+ for (const locale of candidateLocales) {
306
+ const localeSkillsDir = join(localesDir, locale, 'skills');
307
+ const localeCoreDir = join(localesDir, locale, 'core');
308
+
309
+ if (existsSync(localeSkillsDir)) {
310
+ for (const entry of readdirSync(localeSkillsDir, { withFileTypes: true })) {
311
+ if (!entry.isDirectory()) continue;
312
+ const skillFile = join(localeSkillsDir, entry.name, 'SKILL.md');
313
+ if (existsSync(skillFile)) {
314
+ findings.push(...lintLocale(skillFile, locale));
315
+ }
316
+ }
317
+ }
318
+ if (existsSync(localeCoreDir)) {
319
+ for (const entry of readdirSync(localeCoreDir, { withFileTypes: true })) {
320
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
321
+ findings.push(...lintLocale(join(localeCoreDir, entry.name), locale));
322
+ }
323
+ }
324
+ }
325
+ }
326
+
327
+ return findings;
328
+ }
329
+
330
+ /**
331
+ * Convenience helper: split findings into errors and warnings.
332
+ */
333
+ export function partitionFindings(findings) {
334
+ return {
335
+ errors: findings.filter(f => f.severity === 'error'),
336
+ warnings: findings.filter(f => f.severity === 'warn'),
337
+ };
338
+ }
@@ -110,3 +110,42 @@ export class ConfigManager {
110
110
 
111
111
  // Singleton instance for the CLI
112
112
  export const config = new ConfigManager();
113
+
114
+ /**
115
+ * Read the adopter-declared install settings from `.uds/install.yaml`.
116
+ *
117
+ * This file is a *declarative* install descriptor (distinct from
118
+ * `.uds/config.yaml` which holds runtime preferences). Adopters use it to
119
+ * pin install-time choices — most importantly `locale:` — so they do not
120
+ * need to pass `--locale` every time they run `uds init`/`uds update`.
121
+ *
122
+ * Locale resolution order (XSPEC-239 §Req-3 / P1-CLI-2 + P1-CLI-3):
123
+ * CLI `--locale` > install.yaml `locale:` > `UDS_LOCALE` env > LANG > 'en'
124
+ *
125
+ * @param {string} projectPath - Project root path
126
+ * @returns {{locale: string|null, [key: string]: any}} Parsed YAML object, or
127
+ * an empty object when the file is missing/unreadable. `locale` is always
128
+ * present (null when not declared) so callers can safely do `.locale`.
129
+ */
130
+ export function readInstallYaml(projectPath) {
131
+ if (!projectPath) {
132
+ return { locale: null };
133
+ }
134
+
135
+ const filePath = path.join(projectPath, '.uds', 'install.yaml');
136
+ if (!fs.existsSync(filePath)) {
137
+ return { locale: null };
138
+ }
139
+
140
+ try {
141
+ const content = fs.readFileSync(filePath, 'utf8');
142
+ const parsed = yaml.load(content);
143
+ if (!parsed || typeof parsed !== 'object') {
144
+ return { locale: null };
145
+ }
146
+ return { locale: null, ...parsed };
147
+ } catch (error) {
148
+ console.warn(`Warning: Failed to load .uds/install.yaml: ${error.message}`);
149
+ return { locale: null };
150
+ }
151
+ }
@@ -197,13 +197,20 @@ export async function installSkillsForAgent(agent, level, skillNames = null, pro
197
197
  targetDir,
198
198
  installed: [],
199
199
  errors: [],
200
- fileHashes: {} // New: file hashes for installed skills
200
+ fileHashes: {}, // New: file hashes for installed skills
201
+ // List of skill names that requested a localized variant but fell back to
202
+ // the English source because no locale variant exists for them.
203
+ // Populated only when `locale` is a localized locale (not 'en').
204
+ localeFallbacks: []
201
205
  };
202
206
 
203
207
  for (const skillName of toInstall) {
204
208
  const result = installSingleSkill(skillName, targetDir, locale);
205
209
  if (result.success) {
206
210
  results.installed.push(skillName);
211
+ if (result.fallbackToEn) {
212
+ results.localeFallbacks.push(skillName);
213
+ }
207
214
  } else {
208
215
  results.errors.push({ skill: skillName, error: result.error });
209
216
  results.success = false;
@@ -356,11 +363,17 @@ function mergeSkillFrontmatter(enSourceDir, targetDir) {
356
363
  }
357
364
 
358
365
  /**
359
- * Install a single skill to a target directory
366
+ * Install a single skill to a target directory.
367
+ *
368
+ * When `locale` requests a localized variant but no localized directory exists
369
+ * for the requested skill, the function silently copies the English source and
370
+ * sets `fallbackToEn: true` on the result so callers can surface a WARN at the
371
+ * end of the install run (XSPEC-239 §Req-3 / P1-CLI-1).
372
+ *
360
373
  * @param {string} skillName - Skill name
361
374
  * @param {string} targetBaseDir - Target base directory
362
375
  * @param {string} locale - Locale for skill content (default: 'en')
363
- * @returns {Object} Result
376
+ * @returns {{success: boolean, skillName: string, path?: string, error?: string, fallbackToEn?: boolean}} Result
364
377
  */
365
378
  function installSingleSkill(skillName, targetBaseDir, locale = 'en') {
366
379
  const enSourceDir = join(SKILLS_LOCAL_DIR, skillName);
@@ -369,6 +382,7 @@ function installSingleSkill(skillName, targetBaseDir, locale = 'en') {
369
382
  // Determine the actual source directory based on locale
370
383
  let sourceDir = enSourceDir;
371
384
  let needsFrontmatterMerge = false;
385
+ let fallbackToEn = false;
372
386
 
373
387
  if (isLocalizedLocale(locale)) {
374
388
  const localizedDir = getLocalizedSkillsSourceDir(locale);
@@ -376,8 +390,11 @@ function installSingleSkill(skillName, targetBaseDir, locale = 'en') {
376
390
  if (existsSync(localizedSkillDir)) {
377
391
  sourceDir = localizedSkillDir;
378
392
  needsFrontmatterMerge = true;
393
+ } else {
394
+ // Locale requested but missing for this skill — fall back to English source.
395
+ // Flag the result so the caller can aggregate a WARN.
396
+ fallbackToEn = true;
379
397
  }
380
- // else: fall back to English source
381
398
  }
382
399
 
383
400
  if (!existsSync(sourceDir)) {
@@ -410,12 +427,13 @@ function installSingleSkill(skillName, targetBaseDir, locale = 'en') {
410
427
  mergeSkillFrontmatter(enSourceDir, targetDir);
411
428
  }
412
429
 
413
- return { success: true, skillName, path: targetDir };
430
+ return { success: true, skillName, path: targetDir, fallbackToEn };
414
431
  } catch (error) {
415
432
  return {
416
433
  success: false,
417
434
  skillName,
418
- error: error.message
435
+ error: error.message,
436
+ fallbackToEn
419
437
  };
420
438
  }
421
439
  }
@@ -909,9 +927,14 @@ export async function installSkillsToMultipleAgents(installations, skillNames =
909
927
  installations: [],
910
928
  totalInstalled: 0,
911
929
  totalErrors: 0,
912
- allFileHashes: {} // New: combined file hashes from all installations
930
+ allFileHashes: {}, // New: combined file hashes from all installations
931
+ // Aggregated set of skill names that fell back to English across all
932
+ // agents/levels. Used by the high-level installer to print a single WARN.
933
+ localeFallbacks: []
913
934
  };
914
935
 
936
+ const fallbackSet = new Set();
937
+
915
938
  for (const { agent, level } of uniqueInstallations) {
916
939
  const result = await installSkillsForAgent(agent, level, skillNames, projectPath, locale);
917
940
  results.installations.push(result);
@@ -926,8 +949,17 @@ export async function installSkillsToMultipleAgents(installations, skillNames =
926
949
  if (result.fileHashes) {
927
950
  Object.assign(results.allFileHashes, result.fileHashes);
928
951
  }
952
+
953
+ // Merge locale fallbacks (dedupe across agents)
954
+ if (Array.isArray(result.localeFallbacks)) {
955
+ for (const name of result.localeFallbacks) {
956
+ fallbackSet.add(name);
957
+ }
958
+ }
929
959
  }
930
960
 
961
+ results.localeFallbacks = Array.from(fallbackSet).sort();
962
+
931
963
  return results;
932
964
  }
933
965
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "version": "5.14.0",
3
+ "version": "5.15.1",
4
4
  "lastUpdated": "2026-05-13",
5
5
  "description": "Standards registry for universal-dev-standards with integrated skills and AI-optimized formats",
6
6
  "formats": {
@@ -58,14 +58,14 @@
58
58
  "standards": {
59
59
  "name": "universal-dev-standards",
60
60
  "url": "https://github.com/AsiaOstrich/universal-dev-standards",
61
- "version": "5.14.0"
61
+ "version": "5.15.1"
62
62
  },
63
63
  "skills": {
64
64
  "name": "universal-dev-standards",
65
65
  "url": "https://github.com/AsiaOstrich/universal-dev-standards",
66
66
  "localPath": "skills",
67
67
  "rawUrl": "https://raw.githubusercontent.com/AsiaOstrich/universal-dev-standards/main/skills",
68
- "version": "5.14.0",
68
+ "version": "5.15.1",
69
69
  "note": "Skills are now included in the main repository under skills/"
70
70
  }
71
71
  },
@@ -1490,6 +1490,18 @@
1490
1490
  "skillName": null,
1491
1491
  "description": "System for capturing, retrieving, and enforcing project-specific context, architectural decisions, and domain knowledge"
1492
1492
  },
1493
+ {
1494
+ "id": "knowledge-graph-memory",
1495
+ "name": "Knowledge Graph Memory Standards",
1496
+ "nameZh": "知識圖記憶標準",
1497
+ "source": {
1498
+ "human": "core/knowledge-graph-memory.md",
1499
+ "ai": "ai/standards/knowledge-graph-memory.ai.yaml"
1500
+ },
1501
+ "category": "reference",
1502
+ "skillName": "knowledge-graph",
1503
+ "description": "Relationship schema for traversing specs, decisions, and code as a graph (related/impacts/supersedes front-matter -> IMPACTS/SUPERSEDES edges); complements vector memory with structural multi-hop queries; engine-optional with Markdown degradation; basis for self-evolving (SAGE) confidence"
1504
+ },
1493
1505
  {
1494
1506
  "id": "context-aware-loading",
1495
1507
  "name": "Context-Aware Standard Loading",
@@ -2338,7 +2350,7 @@
2338
2350
  "id": "license-compliance",
2339
2351
  "name": "License Compliance Standards",
2340
2352
  "nameZh": "授權合規標準",
2341
- "version": "5.14.0",
2353
+ "version": "5.15.1",
2342
2354
  "source": {
2343
2355
  "human": "core/license-compliance.md",
2344
2356
  "ai": "ai/standards/license-compliance.ai.yaml"