universal-dev-standards 6.1.1 → 6.2.0
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/bundled/locales/zh-CN/CHANGELOG.md +33 -3
- package/bundled/locales/zh-CN/README.md +75 -33
- package/bundled/locales/zh-CN/SECURITY.md +1 -1
- package/bundled/locales/zh-CN/core/behavior-snapshot.md +2 -2
- package/bundled/locales/zh-CN/core/data-migration-testing.md +2 -2
- package/bundled/locales/zh-CN/docs/CHEATSHEET.md +4 -4
- package/bundled/locales/zh-CN/docs/CLI-INIT-OPTIONS.md +9 -3
- package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +10 -11
- package/bundled/locales/zh-CN/docs/USER-MANUAL.md +40 -21
- package/bundled/locales/zh-CN/integrations/gemini-cli/README.md +12 -0
- package/bundled/locales/zh-CN/skills/ac-coverage/SKILL.md +11 -5
- package/bundled/locales/zh-CN/skills/adr-assistant/SKILL.md +2 -2
- package/bundled/locales/zh-CN/skills/commands/brainstorm.md +2 -2
- package/bundled/locales/zh-CN/skills/commit-standards/SKILL.md +2 -2
- package/bundled/locales/zh-CN/skills/contract-test-assistant/SKILL.md +2 -2
- package/bundled/locales/zh-CN/skills/deploy-assistant/SKILL.md +2 -2
- package/bundled/locales/zh-CN/skills/dev-methodology/SKILL.md +2 -2
- package/bundled/locales/zh-CN/skills/dev-workflow-guide/SKILL.md +2 -2
- package/bundled/locales/zh-CN/skills/journey-test-assistant/SKILL.md +2 -2
- package/bundled/locales/zh-CN/skills/knowledge-graph/SKILL.md +2 -2
- package/bundled/locales/zh-CN/skills/knowledge-graph/guide.md +2 -2
- package/bundled/locales/zh-CN/skills/migration-assistant/SKILL.md +188 -2
- package/bundled/locales/zh-CN/skills/observability-assistant/guide.md +2 -2
- package/bundled/locales/zh-CN/skills/orchestrate/SKILL.md +2 -2
- package/bundled/locales/zh-CN/skills/plan/SKILL.md +2 -2
- package/bundled/locales/zh-CN/skills/push/SKILL.md +2 -2
- package/bundled/locales/zh-CN/skills/retrospective-assistant/SKILL.md +2 -2
- package/bundled/locales/zh-CN/skills/reverse-engineer/SKILL.md +2 -2
- package/bundled/locales/zh-CN/skills/runbook-assistant/guide.md +2 -2
- package/bundled/locales/zh-CN/skills/skill-builder/SKILL.md +2 -2
- package/bundled/locales/zh-CN/skills/slo-assistant/guide.md +2 -2
- package/bundled/locales/zh-CN/skills/spec-derivation/SKILL.md +2 -2
- package/bundled/locales/zh-CN/skills/spec-driven-dev/SKILL.md +40 -2
- package/bundled/locales/zh-CN/skills/sweep/SKILL.md +2 -2
- package/bundled/locales/zh-CN/skills/testing-guide/SKILL.md +2 -2
- package/bundled/locales/zh-TW/CHANGELOG.md +34 -3
- package/bundled/locales/zh-TW/README.md +75 -33
- package/bundled/locales/zh-TW/SECURITY.md +1 -1
- package/bundled/locales/zh-TW/core/audit-trail.md +2 -2
- package/bundled/locales/zh-TW/core/behavior-snapshot.md +2 -2
- package/bundled/locales/zh-TW/core/browser-compatibility-standards.md +18 -7
- package/bundled/locales/zh-TW/docs/CHEATSHEET.md +4 -4
- package/bundled/locales/zh-TW/docs/CLI-INIT-OPTIONS.md +9 -3
- package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +10 -11
- package/bundled/locales/zh-TW/docs/USER-MANUAL.md +40 -21
- package/bundled/locales/zh-TW/integrations/gemini-cli/README.md +12 -0
- package/bundled/skills/commands/journey-test.md +80 -6
- package/bundled/skills/commands/skill-builder.md +75 -6
- package/package.json +3 -3
- package/src/commands/check.js +54 -7
- package/src/commands/config.js +9 -0
- package/src/commands/init.js +15 -1
- package/src/commands/release.js +1 -1
- package/src/commands/update.js +82 -31
- package/src/config/ai-agent-paths.js +47 -7
- package/src/core/constants.js +61 -1
- package/src/core/manifest.js +56 -0
- package/src/flow/flow-parser.js +1 -1
- package/src/flow/gate-loader.js +1 -1
- package/src/i18n/messages.js +6 -0
- package/src/installers/standards-installer.js +10 -20
- package/src/reconciler/actual-state-scanner.js +118 -43
- package/src/reconciler/desired-state-calculator.js +157 -43
- package/src/reconciler/diff-engine.js +7 -2
- package/src/reconciler/manifest-migrator.js +5 -2
- package/src/reconciler/plan-executor.js +53 -14
- package/src/uninstallers/integration-uninstaller.js +7 -1
- package/src/utils/config-loader.js +1 -1
- package/src/utils/config-manager.js +1 -1
- package/src/utils/github.js +5 -1
- package/src/utils/hasher.js +7 -4
- package/src/utils/integration-generator.js +121 -78
- package/src/utils/registry.js +39 -0
- package/src/utils/skills-installer.js +49 -34
- package/src/utils/skills-source.js +51 -0
- package/src/utils/standard-fixer.js +1 -1
- package/src/utils/standard-validator.js +1 -1
- package/standards-registry.json +21 -11
package/src/core/manifest.js
CHANGED
|
@@ -389,6 +389,18 @@ function migrateToV340(manifest) {
|
|
|
389
389
|
};
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
+
// 這裡曾有一個 v3.5.0 遷移,把 `integrations` 正規化為工具鍵。**已撤回。**
|
|
393
|
+
//
|
|
394
|
+
// 撤回的理由,是窮舉讀取端之後才看見的:這個欄位有**兩個陣營**,而且大致均衡。
|
|
395
|
+
// 期待檔名 :hasher.js({source:i,target:i})、check.js ×6(join(projectPath, int)、
|
|
396
|
+
// filter(i => i !== relativePath))
|
|
397
|
+
// 期待工具鍵:desired-state-calculator、manifest-migrator、update.js:856(getToolFilePath)
|
|
398
|
+
// 把資料正規化成任何一種,就是修好一邊、弄壞另一邊。
|
|
399
|
+
// 全套 3,240 個測試在正規化之後仍然全綠——那只代表**檔名陣營那幾條路徑沒有覆蓋**,不是安全。
|
|
400
|
+
//
|
|
401
|
+
// 正解是不動資料,讓每個讀取端經 `resolveToolKey` / `resolveIntegrationFile` 容忍兩種形狀。
|
|
402
|
+
// 真要正規化,得排在**所有**讀取端都容錯之後,作為獨立一步。(XSPEC-343 R1)
|
|
403
|
+
|
|
392
404
|
/**
|
|
393
405
|
* Convert a standards array from legacy path format to registry ID format.
|
|
394
406
|
* Entries that already look like IDs (no "/" or ".") are kept as-is.
|
|
@@ -601,4 +613,48 @@ export function areSkillsInstalled(manifest) {
|
|
|
601
613
|
*/
|
|
602
614
|
export function areCommandsInstalled(manifest) {
|
|
603
615
|
return manifest.commands?.installed || false;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Placeholder stored in `skills.names` when skills come from the Claude Code
|
|
620
|
+
* plugin marketplace rather than being copied into the project.
|
|
621
|
+
*/
|
|
622
|
+
export const MARKETPLACE_NAMES_SENTINEL = 'all-via-plugin';
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Merge the names actually installed by a run into a manifest name list.
|
|
626
|
+
*
|
|
627
|
+
* `skills.names` / `commands.names` used to be written by `init` and by no other
|
|
628
|
+
* code path, so they froze on the day a project was set up: machine-setup's said
|
|
629
|
+
* 32 skills across five UDS upgrades while the shipped set grew to 55. Nothing
|
|
630
|
+
* errored — a name list that is merely *incomplete* reads exactly like a complete
|
|
631
|
+
* one, and the reconciler read the gap as "these 40 are no longer wanted".
|
|
632
|
+
* (XSPEC-343 R1/R2)
|
|
633
|
+
*
|
|
634
|
+
* Every code path that installs skills or commands must call this. It lives here
|
|
635
|
+
* rather than in a command module because there are 18 such call sites across
|
|
636
|
+
* update.js and config.js, and a private copy per module is how the writers drift
|
|
637
|
+
* apart again.
|
|
638
|
+
*
|
|
639
|
+
* It only ever adds. A name recorded by an older UDS version that no longer ships
|
|
640
|
+
* (machine-setup still lists `methodology-system`, plus five non-skill directories
|
|
641
|
+
* an old deny-list bug misfiled as skills) stays in the list. Pruning would need
|
|
642
|
+
* to know the name is absent for *every* agent, and over-reporting is harmless
|
|
643
|
+
* now that the reconciler derives desired state from the shipped set instead.
|
|
644
|
+
*
|
|
645
|
+
* @param {string[]} existing - Current manifest list
|
|
646
|
+
* @param {Object} installResult - Result from installSkills/CommandsToMultipleAgents
|
|
647
|
+
* @returns {string[]} Sorted union of existing and newly installed names
|
|
648
|
+
*/
|
|
649
|
+
export function mergeInstalledNames(existing, installResult) {
|
|
650
|
+
const merged = new Set(existing || []);
|
|
651
|
+
// Marketplace installs record a sentinel instead of real names; leave it alone.
|
|
652
|
+
if (merged.has(MARKETPLACE_NAMES_SENTINEL)) return [...merged];
|
|
653
|
+
|
|
654
|
+
for (const agentResult of installResult?.installations || []) {
|
|
655
|
+
for (const name of agentResult?.installed || []) {
|
|
656
|
+
merged.add(name);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return [...merged].sort();
|
|
604
660
|
}
|
package/src/flow/flow-parser.js
CHANGED
package/src/flow/gate-loader.js
CHANGED
package/src/i18n/messages.js
CHANGED
|
@@ -831,6 +831,8 @@ export const messages = {
|
|
|
831
831
|
fileNotFound: 'File not found',
|
|
832
832
|
couldNotRead: 'Could not read file',
|
|
833
833
|
standardsIndexPresent: 'Standards index present',
|
|
834
|
+
standardsIndexCount: 'Index declares {count} standards (matches manifest)',
|
|
835
|
+
standardsIndexCountMismatch: 'Index declares {declared} standards but the manifest has {actual}',
|
|
834
836
|
standardsReferenced: '{count}/{total} standards referenced',
|
|
835
837
|
missingStandardsList: 'Missing: {list}',
|
|
836
838
|
usingMinimalMode: 'Using minimal mode (no standards index)',
|
|
@@ -2063,6 +2065,8 @@ export const messages = {
|
|
|
2063
2065
|
fileNotFound: '找不到檔案',
|
|
2064
2066
|
couldNotRead: '無法讀取檔案',
|
|
2065
2067
|
standardsIndexPresent: '標準索引存在',
|
|
2068
|
+
standardsIndexCount: '索引宣告 {count} 條標準(與 manifest 一致)',
|
|
2069
|
+
standardsIndexCountMismatch: '索引宣告 {declared} 條標準,manifest 實際有 {actual} 條',
|
|
2066
2070
|
standardsReferenced: '{count}/{total} 項標準已參考',
|
|
2067
2071
|
missingStandardsList: '缺少:{list}',
|
|
2068
2072
|
usingMinimalMode: '使用最小模式(無標準索引)',
|
|
@@ -3310,6 +3314,8 @@ export const messages = {
|
|
|
3310
3314
|
fileNotFound: '文件未找到',
|
|
3311
3315
|
couldNotRead: '无法读取文件',
|
|
3312
3316
|
standardsIndexPresent: '标准索引存在',
|
|
3317
|
+
standardsIndexCount: '索引声明 {count} 条标准(与 manifest 一致)',
|
|
3318
|
+
standardsIndexCountMismatch: '索引声明 {declared} 条标准,manifest 实际有 {actual} 条',
|
|
3313
3319
|
standardsReferenced: '已引用 {count}/{total} 项标准',
|
|
3314
3320
|
missingStandardsList: '缺失:{list}',
|
|
3315
3321
|
usingMinimalMode: '使用最小模式(无标准索引)',
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { copyStandard } from '../utils/copier.js';
|
|
11
11
|
import { t } from '../i18n/messages.js';
|
|
12
12
|
import { computeFileHash } from '../utils/hasher.js';
|
|
13
|
+
import { MANIFEST_OPTION_BINDINGS } from '../core/constants.js';
|
|
13
14
|
|
|
14
15
|
// Extension file mappings
|
|
15
16
|
export const EXTENSION_MAPPINGS = {
|
|
@@ -89,28 +90,17 @@ export async function installStandards(config, projectPath) {
|
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
// Copy selected options for this standard
|
|
93
|
+
// Copy selected options for this standard.
|
|
94
|
+
// The manifest-key → (standard, category) mapping lives in MANIFEST_OPTION_BINDINGS
|
|
95
|
+
// so the reconciler computes the same desired set this loop installs. It used to
|
|
96
|
+
// be spelled out inline here and guessed at incorrectly there. (XSPEC-343 R2)
|
|
93
97
|
if (std.options) {
|
|
94
98
|
for (const targetFormat of formatsToUse) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
if (config.standardOptions.merge_strategy) {
|
|
102
|
-
const copied = await copyOptionFiles(std, 'merge_strategy', config.standardOptions.merge_strategy, targetFormat);
|
|
103
|
-
results.standards.push(...copied);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
// Commit message options
|
|
107
|
-
if (std.id === 'commit-message' && (config.standardOptions.output_language || config.standardOptions.commit_language)) {
|
|
108
|
-
const copied = await copyOptionFiles(std, 'output_language', config.standardOptions.output_language || config.standardOptions.commit_language, targetFormat);
|
|
109
|
-
results.standards.push(...copied);
|
|
110
|
-
}
|
|
111
|
-
// Testing options
|
|
112
|
-
if (std.id === 'testing' && config.standardOptions.test_levels) {
|
|
113
|
-
const copied = await copyOptionFiles(std, 'test_level', config.standardOptions.test_levels, targetFormat);
|
|
99
|
+
for (const binding of MANIFEST_OPTION_BINDINGS) {
|
|
100
|
+
if (binding.standardId !== std.id) continue;
|
|
101
|
+
const key = binding.manifestKeys.find(k => config.standardOptions?.[k] != null);
|
|
102
|
+
if (!key) continue;
|
|
103
|
+
const copied = await copyOptionFiles(std, binding.categoryKey, config.standardOptions[key], targetFormat);
|
|
114
104
|
results.standards.push(...copied);
|
|
115
105
|
}
|
|
116
106
|
}
|
|
@@ -12,7 +12,16 @@ import { join, relative } from 'path';
|
|
|
12
12
|
import { readManifest } from '../core/manifest.js';
|
|
13
13
|
import { computeFileHash, computeIntegrationBlockHash } from '../utils/hasher.js';
|
|
14
14
|
import { SUPPORTED_AI_TOOLS, UDS_MARKERS } from '../core/constants.js';
|
|
15
|
-
import { getSkillsDirForAgent, getCommandsDirForAgent } from '../config/ai-agent-paths.js';
|
|
15
|
+
import { getSkillsDirForAgent, getCommandsDirForAgent, getCommandFileExtension } from '../config/ai-agent-paths.js';
|
|
16
|
+
import { getSkillsSourceEntryNames, getAvailableCommandNames } from '../utils/skills-installer.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Files UDS writes into a skills/commands directory for its own bookkeeping.
|
|
20
|
+
* They are not skills or commands and must never be diffed as such — the
|
|
21
|
+
* scanner used to report `.manifest.json` as a stray command, which made the
|
|
22
|
+
* reconciler propose deleting its own installation record. (XSPEC-343 R2)
|
|
23
|
+
*/
|
|
24
|
+
const INSTALLER_BOOKKEEPING_FILES = new Set(['.manifest.json']);
|
|
16
25
|
|
|
17
26
|
/**
|
|
18
27
|
* Scan the actual state of UDS artifacts on disk.
|
|
@@ -241,6 +250,57 @@ function scanIntegrations(state, projectPath) {
|
|
|
241
250
|
}
|
|
242
251
|
}
|
|
243
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Read a directory, tolerating the read itself failing (race, permissions).
|
|
255
|
+
*
|
|
256
|
+
* Deliberately narrow: only `readdirSync` is wrapped. The scan loops used to sit
|
|
257
|
+
* inside the same `catch {}`, so any programming error in the loop body became
|
|
258
|
+
* "this agent has no skills/commands" — an empty actual state indistinguishable
|
|
259
|
+
* from a genuinely empty directory, which downstream reads as "nothing to delete,
|
|
260
|
+
* install everything".
|
|
261
|
+
*/
|
|
262
|
+
function readDirEntries(dirPath) {
|
|
263
|
+
try {
|
|
264
|
+
return readdirSync(dirPath, { withFileTypes: true });
|
|
265
|
+
} catch {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let _sourceEntries = null;
|
|
271
|
+
let _shippedCommands = null;
|
|
272
|
+
|
|
273
|
+
/** Directory names UDS ships under `skills/`, cached per process. */
|
|
274
|
+
function sourceEntryNames() {
|
|
275
|
+
if (_sourceEntries === null) _sourceEntries = getSkillsSourceEntryNames();
|
|
276
|
+
return _sourceEntries;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Command names UDS ships, cached per process. */
|
|
280
|
+
function shippedCommandNames() {
|
|
281
|
+
if (_shippedCommands === null) _shippedCommands = new Set(getAvailableCommandNames());
|
|
282
|
+
return _shippedCommands;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Did UDS put this skill directory here?
|
|
287
|
+
*
|
|
288
|
+
* Two positive signals, either is enough:
|
|
289
|
+
* 1. a directory of the same name exists in UDS's own `skills/` tree — this
|
|
290
|
+
* also covers the non-skill siblings (`_shared`, `agents`, …) that an older
|
|
291
|
+
* CLI copied in by mistake, so they stay cleanable;
|
|
292
|
+
* 2. `manifest.skillHashes` records a file under it — authoritative when
|
|
293
|
+
* present, though in practice it is sparse (dev-platform: 2 entries for 78
|
|
294
|
+
* installed skills), which is exactly why signal 1 has to carry the weight.
|
|
295
|
+
*
|
|
296
|
+
* Anything else is the adopter's own, and is warned about rather than deleted.
|
|
297
|
+
*/
|
|
298
|
+
function isUdsProvenance(skillName, manifest, agent, level) {
|
|
299
|
+
if (sourceEntryNames().has(skillName)) return true;
|
|
300
|
+
const prefix = `${agent}/${level}/${skillName}/`;
|
|
301
|
+
return Object.keys(manifest?.skillHashes || {}).some((k) => k.startsWith(prefix));
|
|
302
|
+
}
|
|
303
|
+
|
|
244
304
|
/**
|
|
245
305
|
* Scan skill installations.
|
|
246
306
|
*/
|
|
@@ -252,27 +312,32 @@ function scanSkills(state, projectPath, manifest) {
|
|
|
252
312
|
const skillsDir = getSkillsDirForAgent(agent, level, projectPath);
|
|
253
313
|
if (!skillsDir || !existsSync(skillsDir)) continue;
|
|
254
314
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
315
|
+
for (const entry of readDirEntries(skillsDir)) {
|
|
316
|
+
if (!entry.isDirectory()) continue;
|
|
317
|
+
const skillName = entry.name;
|
|
318
|
+
const key = `skill:${agent}:${level}:${skillName}`;
|
|
319
|
+
const relPath = level === 'project'
|
|
320
|
+
? getRelativePath(projectPath, join(skillsDir, skillName))
|
|
321
|
+
: join(skillsDir, skillName);
|
|
322
|
+
|
|
323
|
+
state.skills.set(key, {
|
|
324
|
+
relativePath: relPath,
|
|
325
|
+
hash: null, // Directory-level hashes tracked in manifest.skillHashes
|
|
326
|
+
size: null,
|
|
327
|
+
category: 'skill',
|
|
328
|
+
sourcePath: null,
|
|
329
|
+
metadata: {
|
|
330
|
+
agent,
|
|
331
|
+
level,
|
|
332
|
+
skillName,
|
|
333
|
+
scanned: true,
|
|
334
|
+
// Whether UDS is the thing that put this directory here. Everything in
|
|
335
|
+
// the skills folder used to be assumed UDS-managed, so a plan for a repo
|
|
336
|
+
// with hand-written skills proposed deleting them: dev-platform's would
|
|
337
|
+
// have removed fourteen. (XSPEC-343 R2)
|
|
338
|
+
udsManaged: isUdsProvenance(skillName, manifest, agent, level)
|
|
339
|
+
}
|
|
340
|
+
});
|
|
276
341
|
}
|
|
277
342
|
}
|
|
278
343
|
}
|
|
@@ -288,27 +353,37 @@ function scanCommands(state, projectPath, manifest) {
|
|
|
288
353
|
const cmdsDir = getCommandsDirForAgent(agent, level, projectPath);
|
|
289
354
|
if (!cmdsDir || !existsSync(cmdsDir)) continue;
|
|
290
355
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
356
|
+
const ext = getCommandFileExtension(agent);
|
|
357
|
+
|
|
358
|
+
for (const entry of readDirEntries(cmdsDir)) {
|
|
359
|
+
if (INSTALLER_BOOKKEEPING_FILES.has(entry.name)) continue;
|
|
360
|
+
|
|
361
|
+
// Commands are files named `<name><ext>` (or, for some agents, directories).
|
|
362
|
+
// Stripping a hard-coded `.md` left every Gemini command as `commit.toml`,
|
|
363
|
+
// which never matches the desired key `commit`, so all of them were
|
|
364
|
+
// proposed for deletion.
|
|
365
|
+
const cmdName = entry.name.endsWith(ext)
|
|
366
|
+
? entry.name.slice(0, -ext.length)
|
|
367
|
+
: entry.name;
|
|
368
|
+
const key = `command:${agent}:${level}:${cmdName}`;
|
|
369
|
+
const relPath = level === 'project'
|
|
370
|
+
? getRelativePath(projectPath, join(cmdsDir, entry.name))
|
|
371
|
+
: join(cmdsDir, entry.name);
|
|
372
|
+
|
|
373
|
+
state.commands.set(key, {
|
|
374
|
+
relativePath: relPath,
|
|
375
|
+
hash: null,
|
|
376
|
+
size: null,
|
|
377
|
+
category: 'command',
|
|
378
|
+
sourcePath: null,
|
|
379
|
+
metadata: {
|
|
380
|
+
agent,
|
|
381
|
+
level,
|
|
382
|
+
commandName: cmdName,
|
|
383
|
+
scanned: true,
|
|
384
|
+
udsManaged: shippedCommandNames().has(cmdName)
|
|
385
|
+
}
|
|
386
|
+
});
|
|
312
387
|
}
|
|
313
388
|
}
|
|
314
389
|
}
|
|
@@ -9,13 +9,24 @@
|
|
|
9
9
|
|
|
10
10
|
import { join, basename } from 'path';
|
|
11
11
|
import { getAllStandards, getStandardSource, findOption, getOptionSource } from '../utils/registry.js';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
resolveToolKey,
|
|
14
|
+
SUPPORTED_AI_TOOLS,
|
|
15
|
+
MANIFEST_OPTION_BINDINGS,
|
|
16
|
+
OPTIONS_INSTALL_DIR
|
|
17
|
+
} from '../core/constants.js';
|
|
13
18
|
import { PathResolver } from '../core/paths.js';
|
|
14
19
|
import { computeFileHash } from '../utils/hasher.js';
|
|
15
20
|
import {
|
|
16
21
|
getSkillsDirForAgent,
|
|
17
|
-
getCommandsDirForAgent
|
|
22
|
+
getCommandsDirForAgent,
|
|
23
|
+
getCommandFileExtension
|
|
18
24
|
} from '../config/ai-agent-paths.js';
|
|
25
|
+
import {
|
|
26
|
+
getAvailableSkillNames,
|
|
27
|
+
getAvailableCommandNames
|
|
28
|
+
} from '../utils/skills-installer.js';
|
|
29
|
+
import { MARKETPLACE_NAMES_SENTINEL } from '../core/manifest.js';
|
|
19
30
|
|
|
20
31
|
/**
|
|
21
32
|
* @typedef {Object} FileEntry
|
|
@@ -62,6 +73,9 @@ export function calculateDesiredState(projectPath, manifest) {
|
|
|
62
73
|
// 2. Option files
|
|
63
74
|
calculateOptions(state, manifest);
|
|
64
75
|
|
|
76
|
+
// 2b. Extension files (locale/language/framework add-ons)
|
|
77
|
+
calculateExtensions(state, manifest);
|
|
78
|
+
|
|
65
79
|
// 3. Integration files (CLAUDE.md, .cursorrules, etc.)
|
|
66
80
|
calculateIntegrations(state, manifest);
|
|
67
81
|
|
|
@@ -137,6 +151,19 @@ function calculateStandards(state, manifest) {
|
|
|
137
151
|
|
|
138
152
|
/**
|
|
139
153
|
* Calculate expected option files.
|
|
154
|
+
*
|
|
155
|
+
* `manifest.options` is flat — `{ workflow: 'github-flow', test_levels: [...] }` —
|
|
156
|
+
* and the registry nests options under a standard id and a category key whose
|
|
157
|
+
* name is not always the same word (`test_levels` vs `test_level`). This function
|
|
158
|
+
* used to iterate the manifest as if its keys were *standard ids*, look up a
|
|
159
|
+
* standard named `workflow`, find none, and `continue`. It therefore produced an
|
|
160
|
+
* **empty** desired option set for every adopter repo, and every installed option
|
|
161
|
+
* file diffed as "no longer in desired state" — including files the manifest had
|
|
162
|
+
* explicitly selected. EngramGraph's plan proposed deleting all seven of the
|
|
163
|
+
* options its own manifest names. (XSPEC-343 R2)
|
|
164
|
+
*
|
|
165
|
+
* The desired path is flat too: the installer copies into `.standards/options`,
|
|
166
|
+
* not into a `<standardId>/<categoryKey>/` tree.
|
|
140
167
|
*/
|
|
141
168
|
function calculateOptions(state, manifest) {
|
|
142
169
|
const format = manifest.format || 'ai';
|
|
@@ -144,47 +171,89 @@ function calculateOptions(state, manifest) {
|
|
|
144
171
|
|
|
145
172
|
if (!manifest.options) return;
|
|
146
173
|
|
|
147
|
-
for (const
|
|
148
|
-
|
|
174
|
+
for (const binding of MANIFEST_OPTION_BINDINGS) {
|
|
175
|
+
const key = binding.manifestKeys.find(k => manifest.options[k] != null);
|
|
176
|
+
if (!key) continue;
|
|
149
177
|
|
|
150
|
-
const registryEntry = allStandards.find(s => s.id === standardId);
|
|
178
|
+
const registryEntry = allStandards.find(s => s.id === binding.standardId);
|
|
151
179
|
if (!registryEntry) continue;
|
|
152
180
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
181
|
+
const selection = manifest.options[key];
|
|
182
|
+
const selections = Array.isArray(selection) ? selection : [selection];
|
|
183
|
+
|
|
184
|
+
for (const optionId of selections) {
|
|
185
|
+
if (typeof optionId !== 'string') continue;
|
|
186
|
+
|
|
187
|
+
const option = findOption(registryEntry, binding.categoryKey, optionId);
|
|
188
|
+
if (!option) continue;
|
|
189
|
+
|
|
190
|
+
const source = getOptionSource(option, format);
|
|
191
|
+
if (!source) continue;
|
|
192
|
+
|
|
193
|
+
const relativePath = `${OPTIONS_INSTALL_DIR}/${basename(source)}`;
|
|
194
|
+
const absSource = PathResolver.getStandardSource(source);
|
|
195
|
+
|
|
196
|
+
let hash = null;
|
|
197
|
+
let size = null;
|
|
198
|
+
if (absSource) {
|
|
199
|
+
const hashInfo = computeFileHash(absSource);
|
|
200
|
+
if (hashInfo) {
|
|
201
|
+
hash = hashInfo.hash;
|
|
202
|
+
size = hashInfo.size;
|
|
176
203
|
}
|
|
204
|
+
}
|
|
177
205
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
206
|
+
state.options.set(relativePath, {
|
|
207
|
+
relativePath,
|
|
208
|
+
hash,
|
|
209
|
+
size,
|
|
210
|
+
category: 'option',
|
|
211
|
+
sourcePath: absSource,
|
|
212
|
+
metadata: { standardId: binding.standardId, categoryKey: binding.categoryKey, optionId, format }
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Calculate expected extension files.
|
|
220
|
+
*
|
|
221
|
+
* `manifest.extensions` records add-ons installed alongside the standards —
|
|
222
|
+
* locale packs (`extensions/locales/zh-tw.md`), language style guides, framework
|
|
223
|
+
* patterns — copied flat into `.standards/` by the installer.
|
|
224
|
+
*
|
|
225
|
+
* The calculator had no branch for them at all: the word "extensions" appeared
|
|
226
|
+
* exactly once in the whole reconciler, in the scanner's empty initialiser. So
|
|
227
|
+
* every installed extension fell outside the desired state and was proposed for
|
|
228
|
+
* deletion, while `manifest.extensions` went on listing it. Three repos lost
|
|
229
|
+
* their 717-line Traditional Chinese locale pack to this before it was noticed —
|
|
230
|
+
* including one where the removal was applied and committed. (XSPEC-343 R2)
|
|
231
|
+
*/
|
|
232
|
+
function calculateExtensions(state, manifest) {
|
|
233
|
+
for (const source of (manifest.extensions || [])) {
|
|
234
|
+
if (typeof source !== 'string' || !source) continue;
|
|
235
|
+
|
|
236
|
+
const relativePath = `.standards/${basename(source)}`;
|
|
237
|
+
const absSource = PathResolver.getStandardSource(source);
|
|
238
|
+
|
|
239
|
+
let hash = null;
|
|
240
|
+
let size = null;
|
|
241
|
+
if (absSource) {
|
|
242
|
+
const hashInfo = computeFileHash(absSource);
|
|
243
|
+
if (hashInfo) {
|
|
244
|
+
hash = hashInfo.hash;
|
|
245
|
+
size = hashInfo.size;
|
|
186
246
|
}
|
|
187
247
|
}
|
|
248
|
+
|
|
249
|
+
state.standards.set(relativePath, {
|
|
250
|
+
relativePath,
|
|
251
|
+
hash,
|
|
252
|
+
size,
|
|
253
|
+
category: 'standard',
|
|
254
|
+
sourcePath: absSource,
|
|
255
|
+
metadata: { extensionSource: source }
|
|
256
|
+
});
|
|
188
257
|
}
|
|
189
258
|
}
|
|
190
259
|
|
|
@@ -193,8 +262,19 @@ function calculateOptions(state, manifest) {
|
|
|
193
262
|
* For integrations we track the UDS marker block, not the entire file.
|
|
194
263
|
*/
|
|
195
264
|
function calculateIntegrations(state, manifest) {
|
|
196
|
-
for (const
|
|
197
|
-
|
|
265
|
+
for (const entry of (manifest.integrations || [])) {
|
|
266
|
+
// Accept both shapes. `manifest.integrations` is normalised to tool keys by
|
|
267
|
+
// the v3.5.0 migration, but a repo whose manifest has not been re-read and
|
|
268
|
+
// rewritten still holds file paths — and treating those as unknown tools
|
|
269
|
+
// silently produced an EMPTY desired state for integrations, which the
|
|
270
|
+
// reconciler then read as "delete CLAUDE.md's UDS block". Twenty of
|
|
271
|
+
// twenty-one adopter repos were in that state on 2026-07-30.
|
|
272
|
+
//
|
|
273
|
+
// Tolerating the legacy shape here rather than relying on migration alone
|
|
274
|
+
// is deliberate: the failure mode is silent and destructive, and the reader
|
|
275
|
+
// is the last place that can still tell the difference.
|
|
276
|
+
const toolName = resolveToolKey(entry);
|
|
277
|
+
const toolConfig = toolName ? SUPPORTED_AI_TOOLS[toolName] : null;
|
|
198
278
|
if (!toolConfig) continue;
|
|
199
279
|
|
|
200
280
|
const relativePath = toolConfig.file;
|
|
@@ -217,13 +297,35 @@ function calculateIntegrations(state, manifest) {
|
|
|
217
297
|
|
|
218
298
|
/**
|
|
219
299
|
* Calculate expected skill files.
|
|
300
|
+
*
|
|
301
|
+
* The desired set is **what this UDS version ships**, not `manifest.skills.names`.
|
|
302
|
+
* That field is written once by `init` and by no other code path — across five UDS
|
|
303
|
+
* upgrades it stayed frozen at its original 32 entries while the shipped set grew
|
|
304
|
+
* to 55, so every skill installed after init counted as "not desired" and was
|
|
305
|
+
* proposed for deletion. `uds update` always installs the full set (every call site
|
|
306
|
+
* passes `skillNames = null`), so the shipped list *is* the desired list.
|
|
307
|
+
* (XSPEC-343 R1/R2 — 40 of machine-setup's 86 proposed deletions.)
|
|
220
308
|
*/
|
|
221
309
|
function calculateSkills(state, projectPath, manifest) {
|
|
222
310
|
const skills = manifest.skills;
|
|
223
311
|
if (!skills || !skills.installed) return;
|
|
224
312
|
|
|
225
|
-
|
|
226
|
-
|
|
313
|
+
// Marketplace installs live inside the plugin, not the project. There is no
|
|
314
|
+
// project-level desired state to compute, and computing one would mark every
|
|
315
|
+
// on-disk skill for deletion.
|
|
316
|
+
if (skills.location === 'marketplace' || (skills.names || []).includes(MARKETPLACE_NAMES_SENTINEL)) return;
|
|
317
|
+
|
|
318
|
+
const skillNames = getAvailableSkillNames();
|
|
319
|
+
if (skillNames.length === 0) {
|
|
320
|
+
// An empty shipped list means the source tree is unreadable, not that the
|
|
321
|
+
// project should hold no skills. Returning here would leave `desired` empty
|
|
322
|
+
// and every installed skill would diff as a deletion — the failure mode this
|
|
323
|
+
// whole function exists to prevent. Fail loudly instead.
|
|
324
|
+
throw new Error(
|
|
325
|
+
'Cannot compute desired skill state: the UDS skills source directory is empty or unreadable. ' +
|
|
326
|
+
'Refusing to plan (an empty desired state would propose deleting every installed skill).'
|
|
327
|
+
);
|
|
328
|
+
}
|
|
227
329
|
|
|
228
330
|
const installations = skills.installations || [];
|
|
229
331
|
|
|
@@ -254,13 +356,22 @@ function calculateSkills(state, projectPath, manifest) {
|
|
|
254
356
|
|
|
255
357
|
/**
|
|
256
358
|
* Calculate expected command files.
|
|
359
|
+
*
|
|
360
|
+
* Same reasoning as calculateSkills: derive from what UDS ships, not from the
|
|
361
|
+
* frozen `manifest.commands.names`. machine-setup's list said 31 while UDS
|
|
362
|
+
* shipped 51. (XSPEC-343 R1/R2)
|
|
257
363
|
*/
|
|
258
364
|
function calculateCommands(state, projectPath, manifest) {
|
|
259
365
|
const commands = manifest.commands;
|
|
260
366
|
if (!commands || !commands.installed) return;
|
|
261
367
|
|
|
262
|
-
const commandNames =
|
|
263
|
-
if (commandNames.length === 0)
|
|
368
|
+
const commandNames = getAvailableCommandNames();
|
|
369
|
+
if (commandNames.length === 0) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
'Cannot compute desired command state: the UDS commands source directory is empty or unreadable. ' +
|
|
372
|
+
'Refusing to plan (an empty desired state would propose deleting every installed command).'
|
|
373
|
+
);
|
|
374
|
+
}
|
|
264
375
|
|
|
265
376
|
const installations = commands.installations || [];
|
|
266
377
|
|
|
@@ -269,9 +380,12 @@ function calculateCommands(state, projectPath, manifest) {
|
|
|
269
380
|
const commandsDir = getCommandsDirForAgent(agent, level, projectPath);
|
|
270
381
|
if (!commandsDir) continue;
|
|
271
382
|
|
|
383
|
+
// The installed file carries the agent's extension; the key does not.
|
|
384
|
+
const ext = getCommandFileExtension(agent);
|
|
385
|
+
|
|
272
386
|
for (const commandName of commandNames) {
|
|
273
387
|
const relativeBase = level === 'project'
|
|
274
|
-
? getRelativePath(projectPath, join(commandsDir, commandName))
|
|
388
|
+
? getRelativePath(projectPath, join(commandsDir, `${commandName}${ext}`))
|
|
275
389
|
: null;
|
|
276
390
|
|
|
277
391
|
if (relativeBase) {
|
|
@@ -286,8 +286,13 @@ function isUDSManaged(entry) {
|
|
|
286
286
|
// Files in .standards/ are always UDS-managed
|
|
287
287
|
if (entry.relativePath.startsWith('.standards/')) return true;
|
|
288
288
|
|
|
289
|
-
// Scanned skill/command entries
|
|
290
|
-
|
|
289
|
+
// Scanned skill/command entries carry a provenance flag from the scanner.
|
|
290
|
+
// This used to return true unconditionally, which meant every directory in an
|
|
291
|
+
// adopter's skills folder was ours to delete — including hand-written ones.
|
|
292
|
+
// (XSPEC-343 R2)
|
|
293
|
+
if (entry.category === 'skill' || entry.category === 'command') {
|
|
294
|
+
return entry.metadata?.udsManaged !== false;
|
|
295
|
+
}
|
|
291
296
|
|
|
292
297
|
// Integration files with markers
|
|
293
298
|
if (entry.metadata?.hasMarkers) return true;
|