universal-dev-standards 6.7.1 → 6.7.3

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.
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  source: ../../CHANGELOG.md
3
- source_version: 6.7.1
4
- translation_version: 6.7.1
5
- last_synced: 2026-08-17
3
+ source_version: 6.7.3
4
+ translation_version: 6.7.3
5
+ last_synced: 2026-08-18
6
6
  status: current
7
7
  ---
8
8
 
@@ -17,6 +17,35 @@ status: current
17
17
 
18
18
  ## [Unreleased]
19
19
 
20
+ ## [6.7.3] - 2026-08-18
21
+
22
+ ### 修正
23
+
24
+ - **skill 改为内容比对,升级不再重印 55 行毫无意义的变更。** diff 两端都硬写 `hash: null`,于是每个 skill 都是无条件重装。**两端各算来源目录的哈希行不通**——安装不是逐字节复制:locale 版的 `SKILL.md` 会被并入英文 frontmatter(`brainstorm-assistant`:23,753 字节的 zh-TW 来源变成 23,866 字节的安装结果)、来源是运行期依 locale 逐 skill 选择并可回退英文、子目录被跳过。照那样做,55 个全都会显示为内容**变更**,每次升级皆然——**与真的变更无从分辨,比那个已知的无信号更糟**。改为只有一个函数:`resolveSkillFiles(name, locale)` 说明一次安装会包含什么,**安装器写它、计划器哈希它**,两者因此不可能漂移。与真实已安装的项目对账:110 个文件逐字节相符、0 个不符,18 个采用者自写的 skill 正确地解不出来。actual 端**只对 UDS 管理的目录计算哈希**;采用者自己的 skill 永远不被比较、也永远不会变成删除候选。真实升级中的 `Update (57)` 现在是 `Update: 0, Unchanged: 127`。
25
+ - **`uds check --restore` 对 72 个受追踪标准中的 64 个无法还原。** 它拿 `entry.endsWith(fileName)` 去比对 `manifest.standards`,而那些条目**自 3.4.0 起是 ID(`commit-message`)而非路径**——这个比较永远不可能为真。能用的那 8 个是仍存路径格式的 `options/`,**这正是失败从来看起来不像全面失败的原因**;其余一律报告「Could not determine source」。同一段 ID→来源的解析在这个文件里已经存在两次,而这一处从来没拿到过,所以修法是在 `registry.js` 收敛出一支解析器,**与它必须一致的那支文件名解析器配对**。
26
+ - **一个逐字节正确的标准,没办法停止被报成「已修改」。** actual state 是从磁盘算哈希的,所以与上游相符的文件被归为 `unchanged`、不产生动作、也永远不会被重新哈希——而 reconciliation 在计划为空时提前返回、连 manifest 都不写。**没有回头路。** diff 现在报告它**证明过**与上游相同的那些文件,并在提前返回之前补正记录。**刻意做得很窄**:只有在证明磁盘与 desired 相符之后才记录,所以手改永远不会被吸收。把记录同步成磁盘上的任何内容,会让 `uds check` 从此再也报不出任何被改过的标准。
27
+ - **备份不含 skills,而且没有任何东西说出这件事。** skill 是目录,而备份对它们调用 `copyFileSync`,那在每个平台都会抛(本机两个文件系统实测皆 ENOTSUP——**不是临时目录的产物**)。失败被藏了两层:执行器只在**一个都没成功**时中止,于是单一一次成功掩盖了任意数量的失败;备份 manifest 没有 errors 字段,使得「129 个计划路径备了 74 个」在磁盘上与完整备份无从分辨。修正前于真实 repo 测量:备份 manifest 记录 74 个路径、而计划有 129 个动作,其中 **55 个 skill 目录一个都不在里面**——**一个不涵盖它即将覆写的最大一块的回复点**。现在目录递归复制、manifest 记录 `failedToBackUp` 与 `coverage: {planned, backedUp, failed}`,且**任一**备份失败即中止整次执行:拒绝覆写一个没能先复制起来的文件,正是备份的用途。
28
+
29
+ ### 变更
30
+
31
+ - **无条件重装的折叠保留,措辞放宽。** commands 仍然没有内容比对,所以那个分支是活的。它没有跟着 skill 那一半一起移除,因为**一个静默停止套用的折叠,与一个本来就没东西可折的计划,长得一模一样**。
32
+
33
+ ## [6.7.2] - 2026-08-18
34
+
35
+ ### 修正
36
+
37
+ - **`uds update --skills` 更新了全部内容,却永不推进版本标记。** 五个采用 repo 中有四个停在 6.6.0,而同一份 manifest 的 `skills.version` 已经是 6.7.0;唯一推进的,正是那个没装 skills 的。两次执行都 exit 0、都打印「57 succeeded」、都没打印任何失败。先前对此的判读——「有东西报告了失败而它没有浮上来」——**是错的**。探针测到 `results=57 failing=0`、registry 版本解为 `"6.7.1"`、准备写入的值也正确:**reconciler 每一步都做对了,是较晚的一次写入撤销了它。** `update.js` 在命令开头读一次 manifest,那是在 reconciler 执行之前;`updateSkillsOnly()` 随后把那份过期的内存对象写回去覆盖掉它。`updateCommandsOnly()` 有一模一样的缺陷,**它是遍历找出来的,不是撞到的**。两处现在各自重读 manifest,并且**只套用自己拥有的字段**——把整份对象复制回去,会让任何后续步骤新增的字段重蹈同一个缺陷。这件事之所以要紧,是因为该机制自己的注释写着:它存在的目的,就是让每周陈旧度侦察(读的正是这个字段)不再误报。
38
+ - **一份列出 57 项变更、其中 55 项是无条件的计划。** skill 没有内容比对(XSPEC-382 R1),于是每次升级都重印同样的 55 行、理由完全相同,把审阅者真正需要批准的那 2 行埋在底下。现在它们折叠成一行,**而那一行写出自己折了几个**,总数不变——一个不声明自己设限的上限,读起来就像「就这些了」。摘要那行同样改为 `Update: 57 (2 changed, 55 unconditional reinstall)`;单独的 `Update: 57` 是真的,而且什么都没回答,**而决定要不要批准一次升级时读的正是摘要**。
39
+
40
+ ### 变更
41
+
42
+ - **无条件重装的理由字符串收敛为单一导出常量**,不再是产生端一份、渲染端一份。两份副本之间的漂移在这里是无声的:折叠会单纯地停止折叠,而计划看起来与它一直以来的样子一模一样。
43
+
44
+ ### 测试
45
+
46
+ - **为版本标记补上行为层测试,与既有的形状测试并存。** 随修正加入的回归测试断言的是源代码文本——那两个函数含有 `readManifest(projectPath)`——若有人重构成「调用它然后丢掉结果」,它仍然全绿。而这里宣称的是行为,所以 `tests/e2e/update-version-advances.test.js` 会真的跑一次安装、种下探针版本、执行 `update --apply --yes --skills`,再断言标记真的动了。已双向验过:修正在场为绿,还原缺陷为红。
47
+ - 两份新测试都断言**正反两臂**。折叠测试会检查一般计划完全不受影响,因为只验「那 55 行不见了」的测试,对一个把所有 update 行都丢掉的渲染器也照样会过。
48
+
20
49
  ## [6.7.1] - 2026-08-18
21
50
 
22
51
  ### 修正
@@ -15,7 +15,7 @@ status: current
15
15
 
16
16
  > **语言**: [English](../../README.md) | [繁體中文](../zh-TW/README.md) | 简体中文
17
17
 
18
- **版本**: 6.7.1 | **发布日期**: 2026-08-18 | **授权**: [双重授权](../../LICENSE) (CC BY 4.0 + MIT)
18
+ **版本**: 6.7.3 | **发布日期**: 2026-08-18 | **授权**: [双重授权](../../LICENSE) (CC BY 4.0 + MIT)
19
19
 
20
20
  语言无关、框架无关的软件项目文档标准。通过 AI 原生工作流,确保不同技术栈之间的一致性、质量和可维护性。
21
21
 
@@ -13,7 +13,7 @@ status: current
13
13
  <!-- UDS_SUPPORTED_VERSIONS_START -->
14
14
  | 版本 | 支持状态 |
15
15
  |------|--------|
16
- | 6.7.1 | ✅ 最新正式版 |
16
+ | 6.7.3 | ✅ 最新正式版 |
17
17
  | < 6.0.0 | ❌ 已终止支持 |
18
18
  <!-- UDS_SUPPORTED_VERSIONS_END -->
19
19
 
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  source: ../../CHANGELOG.md
3
- source_version: 6.7.1
4
- translation_version: 6.7.1
5
- last_synced: 2026-08-17
3
+ source_version: 6.7.3
4
+ translation_version: 6.7.3
5
+ last_synced: 2026-08-18
6
6
  status: current
7
7
  ---
8
8
 
@@ -17,6 +17,35 @@ status: current
17
17
 
18
18
  ## [Unreleased]
19
19
 
20
+ ## [6.7.3] - 2026-08-18
21
+
22
+ ### 修正
23
+
24
+ - **skill 改為內容比對,升級不再重印 55 列毫無意義的變更。** diff 兩端都硬寫 `hash: null`,於是每個 skill 都是無條件重裝。**兩端各算來源目錄的雜湊行不通**——安裝不是逐位元複製:locale 版的 `SKILL.md` 會被併入英文 frontmatter(`brainstorm-assistant`:23,753 bytes 的 zh-TW 來源變成 23,866 bytes 的安裝結果)、來源是執行期依 locale 逐 skill 選擇並可回退英文、子目錄被略過。照那樣做,55 個全都會顯示為內容**變更**,每次升級皆然——**與真的變更無從分辨,比那個已知的無訊號更糟**。改為只有一個函式:`resolveSkillFiles(name, locale)` 說明一次安裝會包含什麼,**安裝器寫它、計畫器雜湊它**,兩者因此不可能漂移。與真實已安裝的專案對帳:110 個檔逐位元相符、0 個不符,18 個採用者自寫的 skill 正確地解不出來。actual 端**只對 UDS 管理的目錄計算雜湊**;採用者自己的 skill 永遠不被比較、也永遠不會變成刪除候選。真實升級中的 `Update (57)` 現在是 `Update: 0, Unchanged: 127`。
25
+ - **`uds check --restore` 對 72 個受追蹤標準中的 64 個無法還原。** 它拿 `entry.endsWith(fileName)` 去比對 `manifest.standards`,而那些條目**自 3.4.0 起是 ID(`commit-message`)而非路徑**——這個比較永遠不可能為真。會動的那 8 個是仍存路徑格式的 `options/`,**這正是失敗從來看起來不像全面失敗的原因**;其餘一律回報「Could not determine source」。同一段 ID→來源的解析在這個檔案裡已經存在兩次,而這一處從來沒拿到過,所以修法是在 `registry.js` 收斂出一支解析器,**與它必須一致的那支檔名解析器配對**。
26
+ - **一個逐位元正確的標準,沒辦法停止被報成「已修改」。** actual state 是從磁碟算雜湊的,所以與上游相符的檔案被歸為 `unchanged`、不產生動作、也永遠不會被重新雜湊——而 reconciliation 在計畫為空時早退出、連 manifest 都不寫。**沒有回頭路。** diff 現在回報它**證明過**與上游相同的那些檔案,並在早退出之前補正記錄。**刻意做得很窄**:只有在證明磁碟與 desired 相符之後才記錄,所以手改永遠不會被吸收。把記錄同步成磁碟上的任何內容,會讓 `uds check` 從此再也報不出任何被改過的標準。
27
+ - **備份不含 skills,而且沒有任何東西說出這件事。** skill 是目錄,而備份對它們呼叫 `copyFileSync`,那在每個平台都會拋(本機兩個檔案系統實測皆 ENOTSUP——**不是暫存目錄的產物**)。失敗被藏了兩層:執行器只在**一個都沒成功**時中止,於是單一一次成功掩蓋了任意數量的失敗;備份 manifest 沒有 errors 欄位,使得「129 個計畫路徑備了 74 個」在磁碟上與完整備份無從分辨。修正前於真實 repo 量測:備份 manifest 記錄 74 個路徑、而計畫有 129 個動作,其中 **55 個 skill 目錄一個都不在裡面**——**一個不涵蓋它即將覆寫的最大一塊的回復點**。現在目錄遞迴複製、manifest 記錄 `failedToBackUp` 與 `coverage: {planned, backedUp, failed}`,且**任一**備份失敗即中止整次執行:拒絕覆寫一個沒能先複製起來的檔案,正是備份的用途。
28
+
29
+ ### 變更
30
+
31
+ - **無條件重裝的摺疊保留,措辭放寬。** commands 仍然沒有內容比對,所以那個分支是活的。它沒有跟著 skill 那一半一起移除,因為**一個靜默停止套用的摺疊,與一個本來就沒東西可摺的計畫,長得一模一樣**。
32
+
33
+ ## [6.7.2] - 2026-08-18
34
+
35
+ ### 修正
36
+
37
+ - **`uds update --skills` 更新了全部內容,卻永不前進版本標記。** 五個採用 repo 中有四個停在 6.6.0,而同一份 manifest 的 `skills.version` 已經是 6.7.0;唯一前進的,正是那個沒裝 skills 的。兩次執行都 exit 0、都印「57 succeeded」、都沒印任何失敗。先前對此的判讀——「有東西回報了失敗而它沒有浮上來」——**是錯的**。探針量到 `results=57 failing=0`、registry 版本解為 `"6.7.1"`、準備寫入的值也正確:**reconciler 每一步都做對了,是較晚的一次寫入撤銷了它。** `update.js` 在指令開頭讀一次 manifest,那是在 reconciler 執行之前;`updateSkillsOnly()` 隨後把那份過期的記憶體物件寫回去覆蓋掉它。`updateCommandsOnly()` 有一模一樣的缺陷,**它是走訪找出來的,不是撞到的**。兩處現在各自重讀 manifest,並且**只套用自己擁有的欄位**——把整份物件複製回去,會讓任何後續步驟新增的欄位重蹈同一個缺陷。這件事之所以要緊,是因為該機制自己的註解寫著:它存在的目的,就是讓每週陳舊度偵察(讀的正是這個欄位)不再誤報。
38
+ - **一份列出 57 項變更、其中 55 項是無條件的計畫。** skill 沒有內容比對(XSPEC-382 R1),於是每次升級都重印同樣的 55 列、理由完全相同,把審閱者真正需要核可的那 2 列埋在底下。現在它們摺疊成一行,**而那一行寫出自己摺了幾個**,總數不變——一個不聲明自己設限的上限,讀起來就像「就這些了」。摘要那行同樣改為 `Update: 57 (2 changed, 55 unconditional reinstall)`;單獨的 `Update: 57` 是真的,而且什麼都沒回答,**而決定要不要核可一次升級時讀的正是摘要**。
39
+
40
+ ### 變更
41
+
42
+ - **無條件重裝的理由字串收斂為單一匯出常數**,不再是產生端一份、渲染端一份。兩份副本之間的漂移在這裡是無聲的:摺疊會單純地停止摺疊,而計畫看起來與它一直以來的樣子一模一樣。
43
+
44
+ ### 測試
45
+
46
+ - **為版本標記補上行為層測試,與既有的形狀測試並存。** 隨修正加入的迴歸測試斷言的是原始碼文字——那兩個函式含有 `readManifest(projectPath)`——若有人重構成「呼叫它然後丟掉結果」,它仍然全綠。而這裡宣稱的是行為,所以 `tests/e2e/update-version-advances.test.js` 會真的跑一次安裝、種下探針版本、執行 `update --apply --yes --skills`,再斷言標記真的動了。已雙向驗過:修正在場為綠,還原缺陷為紅。
47
+ - 兩份新測試都斷言**正反兩臂**。摺疊測試會檢查一般計畫完全不受影響,因為只驗「那 55 列不見了」的測試,對一個把所有 update 列都丟掉的渲染器也照樣會過。
48
+
20
49
  ## [6.7.1] - 2026-08-18
21
50
 
22
51
  ### 修正
@@ -15,7 +15,7 @@ status: current
15
15
 
16
16
  > **語言**: [English](../../README.md) | 繁體中文 | [简体中文](../zh-CN/README.md)
17
17
 
18
- **版本**: 6.7.1 | **發布日期**: 2026-08-18 | **授權**: [雙重授權](../../LICENSE) (CC BY 4.0 + MIT)
18
+ **版本**: 6.7.3 | **發布日期**: 2026-08-18 | **授權**: [雙重授權](../../LICENSE) (CC BY 4.0 + MIT)
19
19
 
20
20
  語言無關、框架無關的軟體專案文件標準。透過 AI 原生工作流,確保不同技術堆疊之間的一致性、品質和可維護性。
21
21
 
@@ -13,7 +13,7 @@ status: current
13
13
  <!-- UDS_SUPPORTED_VERSIONS_START -->
14
14
  | 版本 | 支援狀態 |
15
15
  |------|--------|
16
- | 6.7.1 | ✅ 最新正式版 |
16
+ | 6.7.3 | ✅ 最新正式版 |
17
17
  | < 6.0.0 | ❌ 已終止支援 |
18
18
  <!-- UDS_SUPPORTED_VERSIONS_END -->
19
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-dev-standards",
3
- "version": "6.7.1",
3
+ "version": "6.7.3",
4
4
  "description": "CLI tool for adopting Universal Development Standards",
5
5
  "keywords": [
6
6
  "documentation",
@@ -7,7 +7,7 @@ import { execSync } from 'child_process';
7
7
  import { readManifest, writeManifest, isInitialized, copyStandard, copyIntegration } from '../utils/copier.js';
8
8
  import {
9
9
  getAllStandards,
10
- getRepositoryInfo, resolveStandardFilename } from '../utils/registry.js';
10
+ getRepositoryInfo, resolveStandardFilename, resolveStandardSourcePath } from '../utils/registry.js';
11
11
  import {
12
12
  computeFileHash,
13
13
  compareFileHash,
@@ -749,11 +749,20 @@ function removeFromManifest(manifest, relativePath) {
749
749
  */
750
750
  export function getSourcePathFromRelative(manifest, relativePath) {
751
751
  const fileName = basename(relativePath);
752
+ const format = manifest.format || 'ai';
752
753
 
753
- // Check standards
754
+ // Check standards.
755
+ //
756
+ // Compare the RESOLVED filename, not the raw entry. Manifests have stored IDs
757
+ // (`commit-message`) rather than paths since 3.4.0, and `'commit-message'
758
+ // .endsWith('commit-message.ai.yaml')` is false for every one of them —
759
+ // so this returned null for 64 of 72 tracked standards and `uds check
760
+ // --restore` reported "Could not determine source" for all of them. The eight
761
+ // that worked were the `options/` entries, still stored as paths, which is
762
+ // why the failure never looked total. (XSPEC-382 R6)
754
763
  for (const std of manifest.standards) {
755
- if (std.endsWith(fileName)) {
756
- return std;
764
+ if (resolveStandardFilename(std, format) === fileName) {
765
+ return resolveStandardSourcePath(std, format);
757
766
  }
758
767
  }
759
768
 
@@ -1933,7 +1933,26 @@ async function updateSkillsOnly(projectPath, manifest, options) {
1933
1933
  Object.assign(manifest.skillHashes, result.allFileHashes);
1934
1934
  }
1935
1935
 
1936
- writeManifest(manifest, projectPath);
1936
+ // 🔴 Re-read before writing. `manifest` was loaded at the top of the update
1937
+ // command, BEFORE the reconciler ran; the reconciler writes its own copy to
1938
+ // disk — including the advanced `upstream.version` — and writing this stale
1939
+ // object back silently reverts it.
1940
+ //
1941
+ // Measured 2026-08-18 with probes inside plan-executor: results=57 failing=0,
1942
+ // registry version "6.7.1", "about to write upstream = {version:'6.7.1'}" —
1943
+ // and the value on disk afterwards was still the old one. The reconciler did
1944
+ // everything right and this write undid it. Both runs exited 0 and printed
1945
+ // "57 succeeded", so a repo could be fully upgraded while its own manifest
1946
+ // said otherwise, and the weekly staleness scout — which reads exactly
1947
+ // `upstream.version` — kept reporting it as behind. (XSPEC-382 R5)
1948
+ //
1949
+ // Only the fields this function owns are carried over: copying the whole
1950
+ // object back would reintroduce the same overwrite for any field a later
1951
+ // step adds.
1952
+ const freshForSkills = readManifest(projectPath) || manifest;
1953
+ freshForSkills.skills = manifest.skills;
1954
+ freshForSkills.skillHashes = manifest.skillHashes;
1955
+ writeManifest(freshForSkills, projectPath);
1937
1956
 
1938
1957
  console.log();
1939
1958
  process.exit(0);
@@ -2040,7 +2059,28 @@ async function updateCommandsOnly(projectPath, manifest, options) {
2040
2059
  replaceCommandHashesForUpdatedAgents(manifest.commandHashes, result.allFileHashes);
2041
2060
  }
2042
2061
 
2043
- writeManifest(manifest, projectPath);
2062
+ // 🔴 Re-read before writing. `manifest` was loaded at the top of the update
2063
+ // command, BEFORE the reconciler ran; the reconciler writes its own copy to
2064
+ // disk — including the advanced `upstream.version` — and writing this stale
2065
+ // object back silently reverts it.
2066
+ //
2067
+ // Measured 2026-08-18 with probes inside plan-executor: results=57 failing=0,
2068
+ // registry version "6.7.1", "about to write upstream = {version:'6.7.1'}" —
2069
+ // and the value on disk afterwards was still the old one. The reconciler did
2070
+ // everything right and this write undid it. Both runs exited 0 and printed
2071
+ // "57 succeeded", so a repo could be fully upgraded while its own manifest
2072
+ // said otherwise, and the weekly staleness scout — which reads exactly
2073
+ // `upstream.version` — kept reporting it as behind. (XSPEC-382 R5)
2074
+ //
2075
+ // Only the fields this function owns are carried over: copying the whole
2076
+ // object back would reintroduce the same overwrite for any field a later
2077
+ // step adds.
2078
+ // Found by traversal, not by hitting it: the same shape existed here, and
2079
+ // fixing only the instance I ran into would have left the class open.
2080
+ const freshForCommands = readManifest(projectPath) || manifest;
2081
+ freshForCommands.commands = manifest.commands;
2082
+ freshForCommands.commandHashes = manifest.commandHashes;
2083
+ writeManifest(freshForCommands, projectPath);
2044
2084
 
2045
2085
  console.log();
2046
2086
  process.exit(0);
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
11
+ import { createHash } from 'crypto';
11
12
  import { join, relative } from 'path';
12
13
  import { readManifest } from '../core/manifest.js';
13
14
  import { computeFileHash, computeIntegrationBlockHash } from '../utils/hasher.js';
@@ -312,6 +313,45 @@ function shippedCommandNames() {
312
313
  // nothing on disk distinguishes them from the adopter's own work. Leaving a few
313
314
  // stale directories with a warning is a better way to fail than deleting files
314
315
  // somebody hand-wrote.
316
+ /**
317
+ * Hash an installed skill directory the way the desired side hashes a resolved
318
+ * one: top-level files only, sorted by name, name and content both fed in.
319
+ *
320
+ * Written against `computeSkillContentHash`'s format rather than calling it,
321
+ * because the input here is paths on disk and there is nothing gained by
322
+ * materialising a second array first. The format is the contract; if it changes
323
+ * in one place and not the other every skill reports as changed, which the
324
+ * paired tests in `skill-content-hash.test.js` exist to catch.
325
+ */
326
+ function hashInstalledSkillDir(dirPath) {
327
+ let entries;
328
+ try {
329
+ entries = readdirSync(dirPath, { withFileTypes: true });
330
+ } catch {
331
+ return null;
332
+ }
333
+
334
+ const names = entries.filter((e) => e.isFile()).map((e) => e.name).sort();
335
+ if (names.length === 0) return null;
336
+
337
+ const h = createHash('sha256');
338
+ for (const name of names) {
339
+ let content;
340
+ try {
341
+ content = readFileSync(join(dirPath, name), 'utf-8');
342
+ } catch {
343
+ // Unreadable file → no trustworthy hash. Returning a partial one would
344
+ // claim the directory matches upstream when part of it was never read.
345
+ return null;
346
+ }
347
+ h.update(name);
348
+ h.update('\0');
349
+ h.update(content);
350
+ h.update('\0');
351
+ }
352
+ return `sha256:${h.digest('hex')}`;
353
+ }
354
+
315
355
  function isUdsProvenance(skillName) {
316
356
  return sourceEntryNames().has(skillName);
317
357
  }
@@ -335,9 +375,18 @@ function scanSkills(state, projectPath, manifest) {
335
375
  ? getRelativePath(projectPath, join(skillsDir, skillName))
336
376
  : join(skillsDir, skillName);
337
377
 
378
+ // Content hash of what is actually installed, over the same file set and
379
+ // the same algorithm the desired side uses (XSPEC-382 R1).
380
+ //
381
+ // Computed ONLY for UDS-managed skills. An adopter's own skill has no
382
+ // desired counterpart, so a hash for it could only ever feed a comparison
383
+ // against nothing — and this scanner has already been the site of a
384
+ // defect that proposed deleting fourteen of dev-platform's hand-written
385
+ // skills. It does not get a second chance to reason about them.
386
+ const udsManaged = isUdsProvenance(skillName);
338
387
  state.skills.set(key, {
339
388
  relativePath: relPath,
340
- hash: null, // Directory-level hashes tracked in manifest.skillHashes
389
+ hash: udsManaged ? hashInstalledSkillDir(join(skillsDir, skillName)) : null,
341
390
  size: null,
342
391
  category: 'skill',
343
392
  sourcePath: null,
@@ -350,7 +399,7 @@ function scanSkills(state, projectPath, manifest) {
350
399
  // the skills folder used to be assumed UDS-managed, so a plan for a repo
351
400
  // with hand-written skills proposed deleting them: dev-platform's would
352
401
  // have removed fourteen. (XSPEC-343 R2)
353
- udsManaged: isUdsProvenance(skillName)
402
+ udsManaged
354
403
  }
355
404
  });
356
405
  }
@@ -17,6 +17,8 @@ import {
17
17
  existsSync,
18
18
  mkdirSync,
19
19
  copyFileSync,
20
+ cpSync,
21
+ statSync,
20
22
  readFileSync,
21
23
  writeFileSync,
22
24
  readdirSync,
@@ -86,7 +88,15 @@ export function createBackup(projectPath, plan) {
86
88
  errors.push(`Failed to write backup .gitignore: ${err.message}`);
87
89
  }
88
90
 
89
- // Backup each file
91
+ // Backup each entry.
92
+ //
93
+ // Skills are DIRECTORIES (`.claude/skills/<name>`), and `copyFileSync` on a
94
+ // directory throws — ENOTSUP on macOS, EISDIR on Linux. Every skill in every
95
+ // plan failed here, and the failures went nowhere (see the partial-failure
96
+ // note below). Measured on a real repo before the fix: a vibeops backup
97
+ // recorded 74 files for a plan of 129 actions, with **0 of the 55 skill
98
+ // directories** among them — a rollback point that did not cover the largest
99
+ // part of what was about to be overwritten. (XSPEC-382 R6)
90
100
  for (const relativePath of filesToBackup) {
91
101
  const sourcePath = join(projectPath, relativePath);
92
102
  if (!existsSync(sourcePath)) continue;
@@ -94,7 +104,11 @@ export function createBackup(projectPath, plan) {
94
104
  const targetPath = join(backupDir, relativePath);
95
105
  try {
96
106
  mkdirSync(dirname(targetPath), { recursive: true });
97
- copyFileSync(sourcePath, targetPath);
107
+ if (statSync(sourcePath).isDirectory()) {
108
+ cpSync(sourcePath, targetPath, { recursive: true });
109
+ } else {
110
+ copyFileSync(sourcePath, targetPath);
111
+ }
98
112
  backedUp.push(relativePath);
99
113
  } catch (err) {
100
114
  errors.push(`Failed to backup ${relativePath}: ${err.message}`);
@@ -116,6 +130,19 @@ export function createBackup(projectPath, plan) {
116
130
  }))
117
131
  },
118
132
  backedUpFiles: backedUp,
133
+ // What could NOT be backed up, and the resulting gap.
134
+ //
135
+ // The manifest previously recorded only successes, with no errors field at
136
+ // all — so a backup covering 74 of 129 planned paths was indistinguishable
137
+ // on disk from one covering all of them. Anyone reaching for this directory
138
+ // is reaching for it because something went wrong; it has to say what it
139
+ // does not contain. (XSPEC-382 R6)
140
+ failedToBackUp: errors.slice(),
141
+ coverage: {
142
+ planned: filesToBackup.length,
143
+ backedUp: backedUp.length,
144
+ failed: filesToBackup.length - backedUp.length
145
+ },
119
146
  projectPath
120
147
  };
121
148
 
@@ -24,7 +24,9 @@ import {
24
24
  } from '../config/ai-agent-paths.js';
25
25
  import {
26
26
  getAvailableSkillNames,
27
- getAvailableCommandNames
27
+ getAvailableCommandNames,
28
+ resolveSkillFiles,
29
+ computeSkillContentHash
28
30
  } from '../utils/skills-installer.js';
29
31
  import { MARKETPLACE_NAMES_SENTINEL } from '../core/manifest.js';
30
32
 
@@ -328,6 +330,9 @@ function calculateSkills(state, projectPath, manifest) {
328
330
  }
329
331
 
330
332
  const installations = skills.installations || [];
333
+ // The locale the project installed with; skills fall back to English per skill
334
+ // when a locale variant is missing, which `resolveSkillFiles` handles.
335
+ const skillsLocale = skills.locale || 'en';
331
336
 
332
337
  for (const installation of installations) {
333
338
  const { agent, level } = installation;
@@ -341,9 +346,19 @@ function calculateSkills(state, projectPath, manifest) {
341
346
  : null;
342
347
 
343
348
  if (relativeBase) {
349
+ // Content hash of what installing this skill WOULD produce.
350
+ //
351
+ // Asked of `resolveSkillFiles` — the same function the installer writes
352
+ // from — so the two answers cannot drift. Hashing the source directory
353
+ // instead does not work: installing is not a verbatim copy (locale
354
+ // selection, English frontmatter merged into localized SKILL.md,
355
+ // subdirectories skipped), and a planner that computes it differently
356
+ // from the installer reports every skill as changed on every upgrade.
357
+ // (XSPEC-382 R1)
358
+ const resolved = resolveSkillFiles(skillName, skillsLocale);
344
359
  state.skills.set(`skill:${agent}:${level}:${skillName}`, {
345
360
  relativePath: relativeBase,
346
- hash: null, // Skill hashes are directory-level, tracked separately
361
+ hash: resolved.error ? null : computeSkillContentHash(resolved.files),
347
362
  size: null,
348
363
  category: 'skill',
349
364
  sourcePath: null,
@@ -39,22 +39,47 @@
39
39
  * @param {boolean} [options.force=false] - Force update even if hashes match
40
40
  * @returns {ReconciliationPlan}
41
41
  */
42
+ /**
43
+ * The reason attached to every skill/command action, because no content
44
+ * comparison is implemented for them (XSPEC-382 R1).
45
+ *
46
+ * Exported and referenced by `formatPlan` rather than spelled out twice: two
47
+ * copies of a string are two things that can drift, and the drift here is
48
+ * silent — the collapse would just stop collapsing and the plan would go back
49
+ * to listing all 57 rows, which is indistinguishable from "it always did that".
50
+ */
51
+ export const UNCONDITIONAL_REINSTALL_REASON = 'no hash available for comparison, re-installing';
52
+
42
53
  export function computeDiff(desired, actual, options = {}) {
43
54
  const { force = false } = options;
44
55
  const actions = [];
45
56
  const warnings = [];
46
57
  const summary = { create: 0, update: 0, delete: 0, unchanged: 0, migrate_block: 0 };
47
58
 
59
+ // Files whose content on disk is byte-identical to what UDS ships (XSPEC-382 R6).
60
+ //
61
+ // These produce no action — there is nothing to install. They are collected
62
+ // so the caller can bring `manifest.fileHashes` back in line with a disk that
63
+ // is already correct. Without this there is no path back: a stale recorded
64
+ // hash makes `uds check` report a pristine file as modified forever, because
65
+ // an unchanged file is never re-hashed and the reconciler returns early when
66
+ // the action list is empty.
67
+ //
68
+ // Only ever recorded when disk === desired. Syncing the record to whatever is
69
+ // on disk would silently absorb a hand edit and empty the integrity check of
70
+ // its purpose — it exists to tell you somebody changed a standard.
71
+ const verifiedPristine = [];
72
+
48
73
  // Diff standards
49
74
  diffCategory(
50
75
  desired.standards, actual.standards,
51
- 'standard', actions, warnings, summary, force
76
+ 'standard', actions, warnings, summary, force, verifiedPristine
52
77
  );
53
78
 
54
79
  // Diff options
55
80
  diffCategory(
56
81
  desired.options, actual.options,
57
- 'option', actions, warnings, summary, force
82
+ 'option', actions, warnings, summary, force, verifiedPristine
58
83
  );
59
84
 
60
85
  // Diff integrations (special: use migrate_block)
@@ -66,22 +91,22 @@ export function computeDiff(desired, actual, options = {}) {
66
91
  // Diff skills
67
92
  diffCategory(
68
93
  desired.skills, actual.skills,
69
- 'skill', actions, warnings, summary, force
94
+ 'skill', actions, warnings, summary, force, verifiedPristine
70
95
  );
71
96
 
72
97
  // Diff commands
73
98
  diffCategory(
74
99
  desired.commands, actual.commands,
75
- 'command', actions, warnings, summary, force
100
+ 'command', actions, warnings, summary, force, verifiedPristine
76
101
  );
77
102
 
78
- return { actions, summary, warnings };
103
+ return { actions, summary, warnings, verifiedPristine };
79
104
  }
80
105
 
81
106
  /**
82
107
  * Diff a single category (standards, options, skills, commands).
83
108
  */
84
- function diffCategory(desiredMap, actualMap, category, actions, warnings, summary, force) {
109
+ function diffCategory(desiredMap, actualMap, category, actions, warnings, summary, force, verifiedPristine = []) {
85
110
  // Check desired entries against actual
86
111
  for (const [key, desiredEntry] of desiredMap) {
87
112
  const actualEntry = actualMap.get(key);
@@ -138,7 +163,7 @@ function diffCategory(desiredMap, actualMap, category, actions, warnings, summar
138
163
  type: 'update',
139
164
  category,
140
165
  path: desiredEntry.relativePath,
141
- reason: 'no hash available for comparison, re-installing',
166
+ reason: UNCONDITIONAL_REINSTALL_REASON,
142
167
  details: {
143
168
  sourcePath: desiredEntry.sourcePath,
144
169
  metadata: desiredEntry.metadata
@@ -149,8 +174,21 @@ function diffCategory(desiredMap, actualMap, category, actions, warnings, summar
149
174
  summary.unchanged++;
150
175
  }
151
176
  } else {
152
- // Hashes match → unchanged
177
+ // Hashes match → unchanged.
178
+ //
179
+ // The file is byte-identical to what UDS ships, so there is nothing to
180
+ // install — but the manifest's recorded hash may still be stale (XSPEC-382
181
+ // R6: a pre-reconciler manifest written back over the reconciler's output
182
+ // reverted `fileHashes` along with `upstream.version`). Recording it here
183
+ // is safe precisely because we got here by proving disk === desired.
153
184
  summary.unchanged++;
185
+ if (actualEntry.hash) {
186
+ verifiedPristine.push({
187
+ path: desiredEntry.relativePath,
188
+ hash: actualEntry.hash,
189
+ size: actualEntry.size ?? null
190
+ });
191
+ }
154
192
  }
155
193
  }
156
194
 
@@ -339,6 +377,14 @@ export function formatPlan(plan) {
339
377
  (grouped[action.type] || []).push(action);
340
378
  }
341
379
 
380
+ // XSPEC-382 R3/R4 — how many updates are unconditional reinstalls rather than
381
+ // real changes. Computed once here because both the Update section and the
382
+ // Summary need it, and a reader of the Summary alone must not be left with a
383
+ // number that answers nothing: `Update: 57` is true and useless.
384
+ const unconditionalCount = grouped.update.filter(
385
+ (a) => a.reason === UNCONDITIONAL_REINSTALL_REASON
386
+ ).length;
387
+
342
388
  if (grouped.create.length > 0) {
343
389
  lines.push(`+ Create (${grouped.create.length}):`);
344
390
  for (const a of grouped.create) {
@@ -348,10 +394,36 @@ export function formatPlan(plan) {
348
394
  }
349
395
 
350
396
  if (grouped.update.length > 0) {
397
+ // XSPEC-382 R3 — collapse the unconditional reinstalls.
398
+ //
399
+ // On a real upgrade this produced `Update (57)` where 55 rows carried one
400
+ // identical reason and only 2 were actual changes — the two a reviewer
401
+ // needs to see, buried under fifty-five that say nothing about this
402
+ // upgrade.
403
+ //
404
+ // R1 has since given SKILLS a content comparison, so those rows are gone;
405
+ // the same upgrade now reports `Update: 0, Unchanged: 127`. COMMANDS still
406
+ // hardcode `hash: null` on both sides, so this branch is live and still
407
+ // needed. It is deliberately not deleted along with the skills half: a
408
+ // collapse that silently stops applying looks exactly like a plan that
409
+ // never had anything to collapse.
410
+ //
411
+ // Collapsed, NOT hidden: the count is printed. A capped list that does not
412
+ // say it was capped reads as "these are all of them", which is the failure
413
+ // this repo keeps finding. Printing the denominator alongside the excluded
414
+ // count is the `class-level-fix` rule.
415
+ const real = grouped.update.filter((a) => a.reason !== UNCONDITIONAL_REINSTALL_REASON);
416
+ const collapsed = unconditionalCount;
417
+
351
418
  lines.push(`~ Update (${grouped.update.length}):`);
352
- for (const a of grouped.update) {
419
+ for (const a of real) {
353
420
  lines.push(` ~ ${a.path} (${a.reason})`);
354
421
  }
422
+ if (collapsed > 0) {
423
+ lines.push(
424
+ ` i ${collapsed} UDS-managed item${collapsed === 1 ? '' : 's'} reinstalled unconditionally — no content comparison is implemented for them (XSPEC-382 R1 covers skills; commands do not have one yet)`
425
+ );
426
+ }
355
427
  lines.push('');
356
428
  }
357
429
 
@@ -373,7 +445,11 @@ export function formatPlan(plan) {
373
445
 
374
446
  lines.push('Summary:');
375
447
  lines.push(` Create: ${plan.summary.create}`);
376
- lines.push(` Update: ${plan.summary.update}`);
448
+ lines.push(
449
+ unconditionalCount > 0
450
+ ? ` Update: ${plan.summary.update} (${plan.summary.update - unconditionalCount} changed, ${unconditionalCount} unconditional reinstall)`
451
+ : ` Update: ${plan.summary.update}`
452
+ );
377
453
  lines.push(` Migrate Block: ${plan.summary.migrate_block}`);
378
454
  lines.push(` Delete: ${plan.summary.delete}`);
379
455
  lines.push(` Unchanged: ${plan.summary.unchanged}`);
@@ -21,7 +21,7 @@
21
21
  * const rollbackResult = rollbackLast(projectPath);
22
22
  */
23
23
 
24
- import { readManifest, needsMigration } from '../core/manifest.js';
24
+ import { readManifest, writeManifest, needsMigration } from '../core/manifest.js';
25
25
  import { migrateAndBackfill } from './manifest-migrator.js';
26
26
  import { calculateDesiredState } from './desired-state-calculator.js';
27
27
  import { scanActualState, legacyDiscovery } from './actual-state-scanner.js';
@@ -45,6 +45,45 @@ import { rollback } from './backup-manager.js';
45
45
  * errors: string[]
46
46
  * }>}
47
47
  */
48
+ /**
49
+ * Bring `manifest.fileHashes` in line with files proved byte-identical to what
50
+ * UDS ships (XSPEC-382 R6).
51
+ *
52
+ * Returns the manifest plus a count of entries corrected, or null when nothing
53
+ * needed correcting — so the caller can skip a pointless write.
54
+ *
55
+ * Deliberately narrow. It only ever writes a hash the reconciler computed from
56
+ * disk AFTER proving that disk matches the desired upstream content, so it
57
+ * cannot absorb a hand edit. Widening it to "sync the record to disk" would
58
+ * make `uds check` incapable of ever reporting a modified standard again, which
59
+ * is the one thing that check exists to do.
60
+ */
61
+ function reconcileFileHashes(manifest, verifiedPristine) {
62
+ if (!verifiedPristine?.length) return null;
63
+
64
+ const current = manifest.fileHashes || {};
65
+ const corrected = {};
66
+ let count = 0;
67
+
68
+ for (const entry of verifiedPristine) {
69
+ const key = entry.path.replace(/\\/g, '/');
70
+ const recorded = current[key];
71
+ if (recorded && recorded.hash === entry.hash && recorded.size === entry.size) continue;
72
+ corrected[key] = {
73
+ hash: entry.hash,
74
+ size: entry.size,
75
+ installedAt: new Date().toISOString()
76
+ };
77
+ count++;
78
+ }
79
+
80
+ if (count === 0) return null;
81
+ return {
82
+ manifest: { ...manifest, fileHashes: { ...current, ...corrected } },
83
+ count
84
+ };
85
+ }
86
+
48
87
  export async function reconcile(projectPath, options = {}) {
49
88
  const { force = false, backup = true, onAction } = options;
50
89
  const errors = [];
@@ -71,18 +110,31 @@ export async function reconcile(projectPath, options = {}) {
71
110
  // Step 4: Compute diff
72
111
  const reconciliationPlan = computeDiff(desired, actual, { force });
73
112
 
113
+ // Step 4b: correct any stale recorded hashes for files already pristine.
114
+ //
115
+ // This runs BEFORE the empty-plan early return on purpose. A manifest whose
116
+ // only problem is a stale hash produces zero actions, and the old early
117
+ // return meant it was never written — so `uds check` reported a byte-perfect
118
+ // file as modified with no path back through normal use. (XSPEC-382 R6)
119
+ // `manifest` above is destructured const, so carry the corrected copy in its
120
+ // own binding rather than reassigning it.
121
+ const hashFix = reconcileFileHashes(manifest, reconciliationPlan.verifiedPristine);
122
+ const effectiveManifest = hashFix ? hashFix.manifest : manifest;
123
+ if (hashFix) writeManifest(effectiveManifest, projectPath);
124
+
74
125
  // Step 5: Execute plan
75
126
  if (reconciliationPlan.actions.length === 0) {
76
127
  return {
77
128
  success: true,
78
129
  plan: reconciliationPlan,
79
130
  execution: null,
80
- manifest,
131
+ manifest: effectiveManifest,
132
+ hashesCorrected: hashFix?.count ?? 0,
81
133
  errors
82
134
  };
83
135
  }
84
136
 
85
- const execution = await executePlan(projectPath, reconciliationPlan, manifest, {
137
+ const execution = await executePlan(projectPath, reconciliationPlan, effectiveManifest, {
86
138
  backup,
87
139
  onAction
88
140
  });
@@ -92,6 +144,7 @@ export async function reconcile(projectPath, options = {}) {
92
144
  plan: reconciliationPlan,
93
145
  execution,
94
146
  manifest: execution.updatedManifest,
147
+ hashesCorrected: hashFix?.count ?? 0,
95
148
  errors: [
96
149
  ...errors,
97
150
  ...execution.results.filter(r => !r.success).map(r => r.error || 'Unknown error')
@@ -71,8 +71,19 @@ export async function executePlan(projectPath, plan, manifest, options = {}) {
71
71
  // Create backup
72
72
  if (backup && !dryRun) {
73
73
  const backupResult = createBackup(projectPath, plan);
74
- if (backupResult.errors.length > 0 && backupResult.backedUp.length === 0) {
75
- // Backup completely failed — abort
74
+ // Abort if ANY planned path could not be backed up — not only if every one
75
+ // failed.
76
+ //
77
+ // The old condition required `backedUp.length === 0`, so a run that backed
78
+ // up 74 paths and failed on 55 proceeded silently and overwrote all 55 with
79
+ // no rollback point. One success was enough to hide any number of failures:
80
+ // the same aggregate-masks-partial-failure shape this repo keeps finding.
81
+ // Those 55 were the skill directories, which failed on every platform
82
+ // because the backup called `copyFileSync` on a directory. With that fixed
83
+ // this branch should be rare — and when it does fire, refusing to overwrite
84
+ // a file we could not copy first is the whole point of taking a backup.
85
+ // (XSPEC-382 R6)
86
+ if (backupResult.errors.length > 0) {
76
87
  return {
77
88
  success: false,
78
89
  backupId: null,
@@ -273,6 +273,37 @@ export function resolveSelectedOptionSources(manifestOptions, format = 'ai') {
273
273
  * @param {string} format - Content format: 'ai' or 'human'
274
274
  * @returns {string|null} Basename as installed, or null if unresolvable
275
275
  */
276
+ /**
277
+ * Resolve a manifest `standards` entry to the source path it was installed from.
278
+ *
279
+ * Manifests have stored IDs (`commit-message`) rather than paths since 3.4.0,
280
+ * and four places in the CLI needed to turn one back into a path. Three of them
281
+ * grew their own copy of the lookup; the fourth — the one `uds check --restore`
282
+ * depends on — never did, and matched `entry.endsWith(fileName)` instead. That
283
+ * cannot match an ID, so restore failed for **64 of 72** tracked standards with
284
+ * "Could not determine source". Only the eight `options/` entries worked,
285
+ * because those are still stored as paths. (XSPEC-382 R6)
286
+ *
287
+ * Paired with `resolveStandardFilename` on purpose: same input handling, same
288
+ * registry lookup, one returns the path and the other its basename. Two
289
+ * separately-written resolvers would answer differently the first time an entry
290
+ * form changed, and neither would be obviously wrong.
291
+ *
292
+ * @param {string} entry - Manifest standards entry: an ID or a legacy path
293
+ * @param {string} format - 'ai' or 'human'
294
+ * @returns {string|null} Source path, or null when unresolvable
295
+ */
296
+ export function resolveStandardSourcePath(entry, format = 'ai') {
297
+ if (typeof entry !== 'string' || entry.length === 0) return null;
298
+
299
+ // A path (option files, and legacy pre-3.4.0 manifests) already names itself.
300
+ if (entry.includes('/') || entry.includes('.')) return entry;
301
+
302
+ const found = getAllStandards().find((s) => s.id === entry);
303
+ if (!found) return null;
304
+ return getStandardSource(found, format) || null;
305
+ }
306
+
276
307
  export function resolveStandardFilename(entry, format = 'ai') {
277
308
  if (typeof entry !== 'string' || entry.length === 0) return null;
278
309
 
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, copyFileSync, statSync, rmSync, unlinkSync } from 'fs';
11
11
  import { dirname, join, basename } from 'path';
12
+ import { createHash } from 'crypto';
12
13
  import { fileURLToPath } from 'url';
13
14
  import {
14
15
  getAgentConfig,
@@ -399,33 +400,137 @@ function mergeSkillFrontmatter(enSourceDir, targetDir) {
399
400
  * @param {string} locale - Locale for skill content (default: 'en')
400
401
  * @returns {{success: boolean, skillName: string, path?: string, error?: string, fallbackToEn?: boolean}} Result
401
402
  */
402
- function installSingleSkill(skillName, targetBaseDir, locale = 'en') {
403
+ /**
404
+ * Resolve what installing a skill would put on disk, without writing anything.
405
+ *
406
+ * This is THE definition of a skill's installed content, and it exists as one
407
+ * function because both the installer and the reconciler need that answer.
408
+ * XSPEC-382 R1 originally proposed hashing the source directory on both sides
409
+ * and letting the existing comparison work. Measurement killed that: installing
410
+ * is not a verbatim copy. Three things sit in between, any one of them fatal to
411
+ * a source-directory hash —
412
+ *
413
+ * 1. a localized SKILL.md gets English frontmatter fields merged in, so the
414
+ * installed file matches no source file (measured: brainstorm-assistant is
415
+ * 23,866 bytes installed against a 23,753-byte zh-TW source);
416
+ * 2. the source directory is chosen at runtime by locale with fallback to
417
+ * English per skill, so there is no single path to hash;
418
+ * 3. subdirectories are skipped, making the install a strict subset.
419
+ *
420
+ * The alternative was a second copy of this logic inside the planner. That is
421
+ * the arrangement this file has already been bitten by twice; two copies answer
422
+ * differently the first time one changes, and a planner that disagrees with the
423
+ * installer reports every skill as changed on every upgrade — worse than the
424
+ * no-signal it replaced, because a false signal is indistinguishable from a
425
+ * real one.
426
+ *
427
+ * All 514 installable files are UTF-8 text (508 .md, 5 .yaml, 1 .json), checked
428
+ * by decoding every one of them, so reading content as a string is lossless.
429
+ *
430
+ * @param {string} skillName
431
+ * @param {string} locale
432
+ * @returns {{files: Array<{name: string, content: string}>, fallbackToEn: boolean, error: string|null}}
433
+ */
434
+ export function resolveSkillFiles(skillName, locale = 'en') {
403
435
  const enSourceDir = join(SKILLS_LOCAL_DIR, skillName);
404
- const targetDir = join(targetBaseDir, skillName);
405
436
 
406
- // Determine the actual source directory based on locale
407
437
  let sourceDir = enSourceDir;
408
438
  let needsFrontmatterMerge = false;
409
439
  let fallbackToEn = false;
410
440
 
411
441
  if (isLocalizedLocale(locale)) {
412
- const localizedDir = getLocalizedSkillsSourceDir(locale);
413
- const localizedSkillDir = join(localizedDir, skillName);
442
+ const localizedSkillDir = join(getLocalizedSkillsSourceDir(locale), skillName);
414
443
  if (existsSync(localizedSkillDir)) {
415
444
  sourceDir = localizedSkillDir;
416
445
  needsFrontmatterMerge = true;
417
446
  } else {
418
- // Locale requested but missing for this skill — fall back to English source.
419
- // Flag the result so the caller can aggregate a WARN.
420
447
  fallbackToEn = true;
421
448
  }
422
449
  }
423
450
 
424
451
  if (!existsSync(sourceDir)) {
452
+ return { files: [], fallbackToEn, error: `Skill not found: ${skillName}` };
453
+ }
454
+
455
+ const files = [];
456
+ for (const fileName of readdirSync(sourceDir)) {
457
+ const sourcePath = join(sourceDir, fileName);
458
+ // Subdirectories are skipped — matching what the installer has always done.
459
+ if (statSync(sourcePath).isDirectory()) continue;
460
+ files.push({ name: fileName, content: readFileSync(sourcePath, 'utf-8') });
461
+ }
462
+
463
+ if (needsFrontmatterMerge) {
464
+ const skill = files.find((f) => f.name === 'SKILL.md');
465
+ if (skill) {
466
+ const merged = mergeFrontmatterContent(enSourceDir, skill.content);
467
+ if (merged !== null) skill.content = merged;
468
+ }
469
+ }
470
+
471
+ // Sorted so a hash over this list depends on content, not on directory order.
472
+ files.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
473
+ return { files, fallbackToEn, error: null };
474
+ }
475
+
476
+ /**
477
+ * Content hash of a resolved skill, over names and contents.
478
+ *
479
+ * Names are included: two skills with the same bodies under different filenames
480
+ * are different installs, and a hash over contents alone would call a rename
481
+ * "unchanged".
482
+ *
483
+ * @param {Array<{name: string, content: string}>} files
484
+ * @returns {string|null} `sha256:<hex>`, or null for an empty resolution
485
+ */
486
+ export function computeSkillContentHash(files) {
487
+ if (!files?.length) return null;
488
+ const h = createHash('sha256');
489
+ for (const f of files) {
490
+ h.update(f.name);
491
+ h.update('\0');
492
+ h.update(f.content);
493
+ h.update('\0');
494
+ }
495
+ return `sha256:${h.digest('hex')}`;
496
+ }
497
+
498
+ /**
499
+ * The in-memory half of `mergeSkillFrontmatter`, so resolution and installation
500
+ * agree by construction rather than by both being kept up to date.
501
+ *
502
+ * @returns {string|null} merged content, or null when there is nothing to merge
503
+ */
504
+ function mergeFrontmatterContent(enSourceDir, targetContent) {
505
+ const enSkillPath = join(enSourceDir, 'SKILL.md');
506
+ if (!existsSync(enSkillPath)) return null;
507
+
508
+ const enParsed = parseFrontmatter(readFileSync(enSkillPath, 'utf-8'));
509
+ if (!enParsed) return null;
510
+
511
+ const REQUIRED_FIELDS = ['name', 'allowed-tools', 'scope', 'argument-hint', 'disable-model-invocation'];
512
+ const fieldsToMerge = {};
513
+ for (const field of REQUIRED_FIELDS) {
514
+ if (enParsed.frontmatter[field] !== undefined) {
515
+ fieldsToMerge[field] = enParsed.frontmatter[field];
516
+ }
517
+ }
518
+ if (Object.keys(fieldsToMerge).length === 0) return null;
519
+
520
+ return rebuildWithFrontmatter(targetContent, fieldsToMerge);
521
+ }
522
+
523
+ function installSingleSkill(skillName, targetBaseDir, locale = 'en') {
524
+ const targetDir = join(targetBaseDir, skillName);
525
+
526
+ const resolved = resolveSkillFiles(skillName, locale);
527
+ const { fallbackToEn } = resolved;
528
+
529
+ if (resolved.error) {
425
530
  return {
426
531
  success: false,
427
532
  skillName,
428
- error: `Skill not found: ${skillName}`
533
+ error: resolved.error
429
534
  };
430
535
  }
431
536
 
@@ -435,20 +540,11 @@ function installSingleSkill(skillName, targetBaseDir, locale = 'en') {
435
540
  }
436
541
 
437
542
  try {
438
- const files = readdirSync(sourceDir);
439
- for (const fileName of files) {
440
- const sourcePath = join(sourceDir, fileName);
441
- const targetPath = join(targetDir, fileName);
442
-
443
- // Skip directories for now (could be extended to handle subdirs)
444
- if (statSync(sourcePath).isDirectory()) continue;
445
-
446
- copyFileSync(sourcePath, targetPath);
447
- }
448
-
449
- // Merge required frontmatter fields from English source into localized SKILL.md
450
- if (needsFrontmatterMerge) {
451
- mergeSkillFrontmatter(enSourceDir, targetDir);
543
+ // Write what `resolveSkillFiles` says this install is. The planner asks the
544
+ // same function what it WOULD be, so the two cannot disagree — which is the
545
+ // whole point of routing installation through it. (XSPEC-382 R1)
546
+ for (const file of resolved.files) {
547
+ writeFileSync(join(targetDir, file.name), file.content, 'utf-8');
452
548
  }
453
549
 
454
550
  return { success: true, skillName, path: targetDir, fallbackToEn };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "version": "6.7.1",
3
+ "version": "6.7.3",
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": "6.7.1"
61
+ "version": "6.7.3"
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": "6.7.1",
68
+ "version": "6.7.3",
69
69
  "note": "Skills are now included in the main repository under skills/"
70
70
  }
71
71
  },
@@ -2260,7 +2260,7 @@
2260
2260
  "id": "license-compliance",
2261
2261
  "name": "License Compliance Standards",
2262
2262
  "nameZh": "授權合規標準",
2263
- "version": "6.7.1",
2263
+ "version": "6.7.3",
2264
2264
  "source": {
2265
2265
  "human": "core/license-compliance.md",
2266
2266
  "ai": "ai/standards/license-compliance.ai.yaml"
@@ -2272,7 +2272,7 @@
2272
2272
  "id": "verification-oracle",
2273
2273
  "name": "Verification Oracle Standards",
2274
2274
  "nameZh": "驗證 Oracle 標準",
2275
- "version": "6.7.1",
2275
+ "version": "6.7.3",
2276
2276
  "source": {
2277
2277
  "human": "core/verification-oracle.md",
2278
2278
  "ai": "ai/standards/verification-oracle.ai.yaml"
@@ -2284,7 +2284,7 @@
2284
2284
  "id": "model-provenance",
2285
2285
  "name": "Model Provenance Policy Standards",
2286
2286
  "nameZh": "模型來源政策標準",
2287
- "version": "6.7.1",
2287
+ "version": "6.7.3",
2288
2288
  "source": {
2289
2289
  "human": "core/model-provenance.md",
2290
2290
  "ai": "ai/standards/model-provenance.ai.yaml"
@@ -2296,7 +2296,7 @@
2296
2296
  "id": "resource-cost-boundary",
2297
2297
  "name": "Resource / Cost Boundary Declaration Standards",
2298
2298
  "nameZh": "資源/成本邊界宣告標準",
2299
- "version": "6.7.1",
2299
+ "version": "6.7.3",
2300
2300
  "source": {
2301
2301
  "human": "core/resource-cost-boundary.md",
2302
2302
  "ai": "ai/standards/resource-cost-boundary.ai.yaml"