@fitlab-ai/agent-infra 0.8.4 → 0.8.5

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 (51) hide show
  1. package/dist/lib/defaults.json +8 -0
  2. package/dist/lib/init.js +5 -0
  3. package/dist/lib/sandbox/commands/create.js +30 -4
  4. package/dist/lib/sandbox/engine.js +33 -3
  5. package/dist/lib/sandbox/engines/colima.js +2 -2
  6. package/dist/lib/sandbox/runtimes/base.dockerfile +4 -0
  7. package/dist/lib/server/daemon.js +9 -0
  8. package/dist/lib/server/process-control.js +66 -73
  9. package/dist/lib/server/process-state.js +168 -0
  10. package/dist/lib/update.js +2 -0
  11. package/lib/defaults.json +8 -0
  12. package/lib/init.ts +9 -0
  13. package/lib/sandbox/commands/create.ts +32 -3
  14. package/lib/sandbox/engine.ts +37 -3
  15. package/lib/sandbox/engines/colima.ts +2 -2
  16. package/lib/sandbox/runtimes/base.dockerfile +4 -0
  17. package/lib/server/daemon.ts +9 -0
  18. package/lib/server/process-control.ts +70 -71
  19. package/lib/server/process-state.ts +183 -0
  20. package/lib/update.ts +6 -0
  21. package/package.json +2 -1
  22. package/runtime/platform-adapters/platform-sync.github.js +1501 -0
  23. package/templates/.agents/README.en.md +2 -0
  24. package/templates/.agents/README.zh-CN.md +2 -0
  25. package/templates/.agents/rules/review-handshake.en.md +5 -4
  26. package/templates/.agents/rules/review-handshake.zh-CN.md +5 -4
  27. package/templates/.agents/scripts/lib/agent-infra-package.js +149 -0
  28. package/templates/.agents/scripts/lib/post-review-commit.js +5 -0
  29. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +25 -1459
  30. package/templates/.agents/scripts/review-diff-fingerprint.js +95 -24
  31. package/templates/.agents/scripts/validate-artifact.js +68 -55
  32. package/templates/.agents/skills/commit/SKILL.en.md +5 -3
  33. package/templates/.agents/skills/commit/SKILL.zh-CN.md +5 -3
  34. package/templates/.agents/skills/commit/reference/task-status-update.en.md +29 -2
  35. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +29 -2
  36. package/templates/.agents/skills/complete-task/SKILL.en.md +2 -2
  37. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -2
  38. package/templates/.agents/skills/complete-task/config/verify.en.json +1 -0
  39. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +1 -0
  40. package/templates/.agents/skills/init-milestones/SKILL.en.md +2 -2
  41. package/templates/.agents/skills/init-milestones/SKILL.zh-CN.md +2 -2
  42. package/templates/.agents/skills/init-milestones/scripts/init-milestones.github.sh +5 -4
  43. package/templates/.agents/skills/review-code/SKILL.en.md +2 -2
  44. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +2 -2
  45. package/templates/.agents/skills/review-code/config/verify.en.json +2 -1
  46. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +2 -1
  47. package/templates/.agents/skills/review-code/reference/report-template.en.md +2 -1
  48. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +2 -1
  49. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +5 -1
  50. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +5 -1
  51. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +174 -136
@@ -76,7 +76,7 @@ tail .agents/workspace/active/{task-id}/task.md
76
76
  - 一次性记录 `R=$(git rev-parse HEAD)`;本轮报告、指纹和任务审查事实都复用该 R,禁止稍后重新读取 HEAD 代替
77
77
  - `git diff --binary "$R" -- <post-review-globs>` 覆盖已跟踪变更
78
78
  - `git ls-files -o --exclude-standard -z -- <post-review-globs>` 覆盖未跟踪新文件
79
- - `node .agents/scripts/review-diff-fingerprint.js worktree "$R"` 生成审查差异指纹,并写入报告
79
+ - `node .agents/scripts/review-diff-fingerprint.js worktree "$R" --format json` 一次生成审查差异指纹 `F` 与审查快照树 `T`,并写入报告
80
80
 
81
81
  > 详细审查标准、严重程度划分和 reviewer 关注点见 `reference/review-criteria.md`。执行此步骤前先读取 `reference/review-criteria.md`。
82
82
  > 测试审查硬门禁:当 `git diff` 触及测试文件时,必须先读取 `.agents/rules/testing-discipline.md` 并逐条核对(尤其"正向已覆盖时不应再加反向断言")。
@@ -99,7 +99,7 @@ date "+%Y-%m-%d %H:%M:%S%z" | sed 's/\([+-][0-9][0-9]\)\([0-9][0-9]\)$/\1:\2/'
99
99
  - `assigned_to`:{当前代理}
100
100
  - `updated_at`:{当前时间}
101
101
  - `agent_infra_version`:按 `.agents/rules/version-stamp.md` 取值
102
- - 若本轮 `总体结论` / `Overall Verdict` 为 `通过` / `Approved`,写入 `last_reviewed_commit: {R}`;是否存在工作区 diff 不影响写入,重复写同一 R 幂等
102
+ - 若本轮 `总体结论` / `Overall Verdict` 为 `通过` / `Approved` 且 `T == R^{tree}`,写入 `last_reviewed_commit: {R}`;若 Approved 快照包含未提交差异,则清除既有 `last_reviewed_commit`,等待 `commit` 锚定
103
103
  - 若本轮结论不是 Approved,保留既有 `last_reviewed_commit`,不得推进或清空
104
104
  - 追加:
105
105
  `- {YYYY-MM-DD HH:mm:ss±HH:MM} — **Review Code (Round {N})** by {agent} — Verdict: {Approved/Changes Requested/Rejected}, blockers: {n}, major: {n}, minor: {n}, Manual-validation: {n} → {artifact-filename}`
@@ -32,7 +32,8 @@
32
32
  "^\\$ ",
33
33
  "^- \\*\\*Overall Verdict\\*\\*[::]\\s*(?:通过|需要修改|拒绝|Approved|Changes Requested|Rejected)\\s*$",
34
34
  "^- \\*\\*Review Baseline Commit\\*\\*[::]\\s*\\S",
35
- "^- \\*\\*Reviewed Diff Fingerprint\\*\\*[::]\\s*sha256:[0-9a-f]{64}\\s*$"
35
+ "^- \\*\\*Reviewed Diff Fingerprint\\*\\*[::]\\s*sha256:[0-9a-f]{64}\\s*$",
36
+ "^- \\*\\*Reviewed Snapshot Tree\\*\\*[::]\\s*[0-9a-f]{40,64}\\s*$"
36
37
  ],
37
38
  "freshness_minutes": 30
38
39
  },
@@ -32,7 +32,8 @@
32
32
  "^\\$ ",
33
33
  "^- \\*\\*总体结论\\*\\*[::]\\s*(?:通过|需要修改|拒绝|Approved|Changes Requested|Rejected)\\s*$",
34
34
  "^- \\*\\*审查基线提交\\*\\*[::]\\s*\\S",
35
- "^- \\*\\*审查差异指纹\\*\\*[::]\\s*sha256:[0-9a-f]{64}\\s*$"
35
+ "^- \\*\\*审查差异指纹\\*\\*[::]\\s*sha256:[0-9a-f]{64}\\s*$",
36
+ "^- \\*\\*审查快照树\\*\\*[::]\\s*[0-9a-f]{40,64}\\s*$"
36
37
  ],
37
38
  "freshness_minutes": 30
38
39
  },
@@ -21,8 +21,9 @@ Use this template when writing `review-code.md` or `review-code-r{N}.md`.
21
21
  - **Reviewer**: {reviewer-name}
22
22
  - **Review Time**: {timestamp}
23
23
  - **Scope**: {file-count and major modules}
24
- - **Review Baseline Commit**: {raw R captured once for this round} (baseline for the complete-task post-review commit gate; see `.agents/rules/review-handshake.md`)
24
+ - **Review Baseline Commit**: {raw R captured once for this round} (diff base only; see `.agents/rules/review-handshake.md`)
25
25
  - **Reviewed Diff Fingerprint**: {raw node .agents/scripts/review-diff-fingerprint.js worktree "$R"}
26
+ - **Reviewed Snapshot Tree**: {tree field from node .agents/scripts/review-diff-fingerprint.js worktree "$R" --format json}
26
27
  - **Overall Verdict**: {Approved / Changes Requested / Rejected} (pick exactly one; combined phrases will fail the verify gate)
27
28
  - **Findings (AI-actionable)**: 0 blockers, 0 majors, 0 minors / **Manual validation**: 0
28
29
 
@@ -21,8 +21,9 @@
21
21
  - **审查者**:{reviewer-name}
22
22
  - **审查时间**:{timestamp}
23
23
  - **审查范围**:{file-count and major modules}
24
- - **审查基线提交**:{本轮一次性捕获的 R 原文}(complete-task 的 post-review commit 门禁基线;详见 `.agents/rules/review-handshake.md`)
24
+ - **审查基线提交**:{本轮一次性捕获的 R 原文}(仅表示 diff base;详见 `.agents/rules/review-handshake.md`)
25
25
  - **审查差异指纹**:{node .agents/scripts/review-diff-fingerprint.js worktree "$R" 原文}
26
+ - **审查快照树**:{node .agents/scripts/review-diff-fingerprint.js worktree "$R" --format json 的 tree 字段}
26
27
  - **总体结论**:{通过 / 需要修改 / 拒绝}(恰取一个;禁止写组合短语,否则 verify gate 失败)
27
28
  - **发现(AI 可处理)**:0 阻塞项,0 主要,0 次要 / **人工校验**:0
28
29
 
@@ -33,7 +33,7 @@ same-path external files, and later external sources win over earlier external
33
33
  sources. Conflicts are recorded in the report. The script then performs:
34
34
  - detect the template source version
35
35
  - File registry sync (`defaults.json` → `.agents/.airc.json`)
36
- - All managed files (language selection → exclude merged/ejected → placeholder rendering → overwrite)
36
+ - All managed files (language selection → exclude merged/ejected → placeholder rendering → write; built-in guarded managed files use a source-baseline three-way comparison)
37
37
  - Ejected files (create only on first install)
38
38
  - `.agents/.airc.json` update (`templateVersion`, file lists)
39
39
 
@@ -47,6 +47,8 @@ The script outputs JSON to stdout. Parse and record the report.
47
47
  list these in the report so users know which files were ignored because a
48
48
  built-in template or later external source won
49
49
  - `managed.written` / `managed.created`: updated / newly created managed files
50
+ - `managed.protected`: guarded managed files whose local user-only modification or deletion was preserved
51
+ - `managed.conflicts`: guarded managed files with unknown origin, two-sided changes, or ownership that cannot be proven during a platform switch; list each target, reason, and three-way hash
50
52
  - `managed.removed`: deleted managed files (including old paths removed during template migrations)
51
53
  - `managed.skippedPlatform`: managed / merged entries skipped because they belong to a different platform
52
54
  - `managed.skippedTUI`: managed / merged entries skipped because they are owned by a built-in TUI that is disabled in `tuis` (customTUI command files under the same path prefix are preserved)
@@ -56,6 +58,8 @@ The script outputs JSON to stdout. Parse and record the report.
56
58
  - `selfUpdate`: whether this is a self-update scenario
57
59
  - `configUpdated`: whether `.agents/.airc.json` was updated
58
60
 
61
+ If `managed.conflicts` is non-empty, output every conflict and stop immediately before Phase B. Do not overwrite a conflicting file or advance its `files.managedBaselines` entry.
62
+
59
63
  ## Phase B: Process merged files (AI intelligent merge)
60
64
 
61
65
  Process each item in the report's `merged.pending` list:
@@ -27,7 +27,7 @@ node .agents/skills/update-agent-infra/scripts/sync-templates.js
27
27
  脚本读取 `.agents/.airc.json`,并通过 npm 自动定位已安装的 `@fitlab-ai/agent-infra/templates/` 目录。同步前会合并 `templates.sources` 中配置的外部模板源;相同文件以内置模板为准,多个外部源之间后者覆盖前者,冲突会写入报告。然后自动完成:
28
28
  - 检测模板源版本
29
29
  - 同步文件注册表(`defaults.json` → `.agents/.airc.json`)
30
- - 处理所有 managed 文件(语言选择 → 排除 merged/ejected → 占位符渲染 → 覆盖写入)
30
+ - 处理所有 managed 文件(语言选择 → 排除 merged/ejected → 占位符渲染 → 写入;内建 guarded managed 文件使用来源基线三方比较)
31
31
  - 处理 ejected 文件(仅首次安装时创建)
32
32
  - 更新 `.agents/.airc.json`(`templateVersion`、文件列表)
33
33
 
@@ -39,6 +39,8 @@ node .agents/skills/update-agent-infra/scripts/sync-templates.js
39
39
  - `templateRoot`:模板文件根目录绝对路径
40
40
  - `templateSources.conflicts`:外部模板源冲突列表;报告中必须显式展示,说明哪些文件因内置模板或后续外部源获胜而被忽略
41
41
  - `managed.written` / `managed.created`:已更新/新建的 managed 文件
42
+ - `managed.protected`:检测到用户单边修改或删除、因此保留本地状态的 guarded managed 文件
43
+ - `managed.conflicts`:来源未知、双边修改或平台切换时无法证明官方所有权的 guarded managed 冲突;必须逐项展示 target、reason 与三方哈希
42
44
  - `managed.removed`:被删除的 managed 文件(包括模板迁移时移除的旧路径)
43
45
  - `managed.skippedPlatform`:因归属其他平台而被跳过的 managed / merged 条目
44
46
  - `managed.skippedTUI`:因 `tuis` 中未启用对应内建 TUI 而被跳过的 managed / merged 条目(落在同一路径前缀下的 customTUI 命令文件会被保留)
@@ -48,6 +50,8 @@ node .agents/skills/update-agent-infra/scripts/sync-templates.js
48
50
  - `selfUpdate`:是否为自更新模式
49
51
  - `configUpdated`:`.agents/.airc.json` 是否已更新
50
52
 
53
+ 如果 `managed.conflicts` 非空,输出全部冲突并立即停止,不进入阶段 B;禁止覆盖冲突文件或推进其 `files.managedBaselines`。
54
+
51
55
  ## 阶段 B:处理 merged 文件(AI 智能合并)
52
56
 
53
57
  根据报告中 `merged.pending` 列表,逐个文件处理。对于每个条目:
@@ -14,11 +14,17 @@
14
14
  */
15
15
 
16
16
  import childProcess from 'node:child_process';
17
+ import crypto from 'node:crypto';
17
18
  import fs from 'node:fs';
18
19
  import os from 'node:os';
19
20
  import path from 'node:path';
20
21
  import { fileURLToPath } from 'node:url';
21
22
 
23
+ import {
24
+ formatAgentInfraPackageError,
25
+ resolveAgentInfraPackage
26
+ } from '../../../scripts/lib/agent-infra-package.js';
27
+
22
28
  const DEFAULTS = {
23
29
  "platform": {
24
30
  "type": "github"
@@ -65,8 +71,16 @@ const DEFAULTS = {
65
71
  ".gemini/commands/",
66
72
  ".git-hooks/check-version-format.sh",
67
73
  ".github/scripts/",
74
+ ".github/workflows/metadata-sync.yml",
75
+ ".github/workflows/pr-label.yml",
76
+ ".github/workflows/status-label.yml",
68
77
  ".opencode/commands/"
69
78
  ],
79
+ "guardedManaged": [
80
+ ".github/workflows/metadata-sync.yml",
81
+ ".github/workflows/pr-label.yml",
82
+ ".github/workflows/status-label.yml"
83
+ ],
70
84
  "merged": [
71
85
  "**/post-release.*",
72
86
  "**/release.*",
@@ -88,7 +102,6 @@ const DEFAULTS = {
88
102
  }
89
103
  };
90
104
 
91
- const PACKAGE_NAME = '@fitlab-ai/agent-infra';
92
105
  const AGENT_INFRA_SANDBOX_TOOL = 'agent-infra';
93
106
  const LEGACY_DEFAULT_SANDBOX_TOOLS = ['claude-code', 'codex', 'gemini-cli', 'opencode'];
94
107
  const DEFAULT_SANDBOX_TOOLS = [AGENT_INFRA_SANDBOX_TOOL, ...LEGACY_DEFAULT_SANDBOX_TOOLS];
@@ -151,6 +164,14 @@ function migrateSandboxTools(cfg) {
151
164
 
152
165
  function norm(p) { return p.replace(/\\/g, '/'); }
153
166
 
167
+ function sha256(content) {
168
+ return `sha256:${crypto.createHash('sha256').update(content).digest('hex')}`;
169
+ }
170
+
171
+ function trustedBaseline(value) {
172
+ return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value) ? value : null;
173
+ }
174
+
154
175
  function normDir(p) {
155
176
  return norm(p).replace(/^\.\//, '').replace(/\/+$/, '');
156
177
  }
@@ -839,117 +860,6 @@ function stripLangVariant(relativePath) {
839
860
  return relativePath;
840
861
  }
841
862
 
842
- function isTemplateDir(dir) {
843
- try {
844
- return fs.statSync(dir).isDirectory();
845
- } catch {
846
- return false;
847
- }
848
- }
849
-
850
- function verifyPackageDir(dir) {
851
- const pkgPath = path.join(dir, 'package.json');
852
- if (!fs.existsSync(pkgPath)) {
853
- return { templateRoot: null, reason: `package.json not found at ${pkgPath}` };
854
- }
855
-
856
- let pkg;
857
- try {
858
- pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
859
- } catch {
860
- return { templateRoot: null, reason: `invalid package.json at ${pkgPath}` };
861
- }
862
-
863
- if (pkg.name !== PACKAGE_NAME) {
864
- const packageName = typeof pkg.name === 'string' && pkg.name ? pkg.name : 'an unknown package';
865
- return { templateRoot: null, reason: `${pkgPath} belongs to ${packageName}` };
866
- }
867
-
868
- const templateRoot = path.join(dir, 'templates');
869
- if (!isTemplateDir(templateRoot)) {
870
- return { templateRoot: null, reason: `templates/ not found at ${templateRoot}` };
871
- }
872
-
873
- return { templateRoot, reason: null };
874
- }
875
-
876
- function resolveUnixTemplateRoot(name) {
877
- let linkPath;
878
- try {
879
- linkPath = childProcess.execSync(`command -v ${name}`, {
880
- encoding: 'utf8',
881
- stdio: ['pipe', 'pipe', 'pipe']
882
- }).trim();
883
- } catch {
884
- return { templateRoot: null, reason: 'not found in PATH' };
885
- }
886
-
887
- if (!linkPath) {
888
- return { templateRoot: null, reason: 'not found in PATH' };
889
- }
890
-
891
- let realPath;
892
- try {
893
- realPath = fs.realpathSync(linkPath);
894
- } catch {
895
- return { templateRoot: null, reason: `cannot resolve symlink target for ${linkPath}` };
896
- }
897
-
898
- let dir = path.dirname(realPath);
899
- while (true) {
900
- const pkgPath = path.join(dir, 'package.json');
901
- if (fs.existsSync(pkgPath)) {
902
- return verifyPackageDir(dir);
903
- }
904
-
905
- const parentDir = path.dirname(dir);
906
- if (parentDir === dir) {
907
- break;
908
- }
909
- dir = parentDir;
910
- }
911
-
912
- return { templateRoot: null, reason: `no package.json found above ${realPath}` };
913
- }
914
-
915
- function resolveWindowsTemplateRoot(name) {
916
- let output;
917
- try {
918
- output = childProcess.execSync(`where ${name}`, {
919
- encoding: 'utf8',
920
- stdio: ['pipe', 'pipe', 'pipe']
921
- }).trim();
922
- } catch {
923
- return { templateRoot: null, reason: 'not found in PATH' };
924
- }
925
-
926
- const wrapperPaths = output.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
927
- if (wrapperPaths.length === 0) {
928
- return { templateRoot: null, reason: 'not found in PATH' };
929
- }
930
-
931
- const wrapperPath = wrapperPaths.find(line => /\.cmd$/i.test(line)) || wrapperPaths[0];
932
- const packageDir = path.join(path.dirname(wrapperPath), 'node_modules', '@fitlab-ai', 'agent-infra');
933
- return verifyPackageDir(packageDir);
934
- }
935
-
936
- function resolveTemplateRoot() {
937
- const resolver = process.platform === 'win32'
938
- ? resolveWindowsTemplateRoot
939
- : resolveUnixTemplateRoot;
940
- const errors = [];
941
-
942
- for (const name of ['ai', 'agent-infra']) {
943
- const result = resolver(name);
944
- if (result.templateRoot) {
945
- return result.templateRoot;
946
- }
947
- errors.push({ name, reason: result.reason });
948
- }
949
-
950
- return { templateRoot: null, errors };
951
- }
952
-
953
863
  function isBinary(fp) {
954
864
  const fd = fs.openSync(fp, 'r');
955
865
  const buf = Buffer.alloc(8192);
@@ -1037,25 +947,14 @@ function syncTemplates(projectRoot, templateRootOverride) {
1037
947
  const configPathRel = norm(path.relative(projectRoot, cfgPath));
1038
948
  let templateRoot = templateRootOverride;
1039
949
  if (!templateRoot) {
1040
- const resolvedTemplateRoot = resolveTemplateRoot();
1041
- if (typeof resolvedTemplateRoot === 'string') {
1042
- templateRoot = resolvedTemplateRoot;
950
+ const packageResolution = resolveAgentInfraPackage({
951
+ startPath: path.join(projectRoot, '.agents', 'scripts', 'lib', 'agent-infra-package.js')
952
+ });
953
+ if (packageResolution.templateRoot && fs.existsSync(packageResolution.templateRoot)) {
954
+ templateRoot = packageResolution.templateRoot;
1043
955
  } else {
1044
- const details = resolvedTemplateRoot.errors
1045
- .map(({ name, reason }) => ` - ${name}: ${reason}`)
1046
- .join('\n');
1047
956
  return {
1048
- error: [
1049
- 'Template source not found.',
1050
- '',
1051
- 'Attempted binary lookups:',
1052
- details,
1053
- '',
1054
- 'Please ensure agent-infra is installed and available on PATH.',
1055
- 'If already installed, upgrade to the latest version or reinstall:',
1056
- ' npm install -g @fitlab-ai/agent-infra',
1057
- ' brew upgrade fitlab-ai/agent-infra/agent-infra || brew install fitlab-ai/agent-infra/agent-infra'
1058
- ].join('\n')
957
+ error: formatAgentInfraPackageError(packageResolution)
1059
958
  };
1060
959
  }
1061
960
  }
@@ -1073,6 +972,19 @@ function syncTemplates(projectRoot, templateRootOverride) {
1073
972
  const managed = [...(cfg.files.managed || [])];
1074
973
  const merged = [...(cfg.files.merged || [])];
1075
974
  const ejected = [...(cfg.files.ejected || [])];
975
+ const guardedManaged = new Set((DEFAULTS.files.guardedManaged || []).map(norm));
976
+ const managedBaselines = cfg.files.managedBaselines && typeof cfg.files.managedBaselines === 'object'
977
+ && !Array.isArray(cfg.files.managedBaselines)
978
+ ? { ...cfg.files.managedBaselines }
979
+ : {};
980
+ let baselinesChanged = false;
981
+
982
+ for (const target of Object.keys(managedBaselines)) {
983
+ if (!guardedManaged.has(norm(target))) {
984
+ delete managedBaselines[target];
985
+ baselinesChanged = true;
986
+ }
987
+ }
1076
988
 
1077
989
  const report = {
1078
990
  templateVersion: version,
@@ -1085,7 +997,17 @@ function syncTemplates(projectRoot, templateRootOverride) {
1085
997
  errors: [],
1086
998
  conflicts: []
1087
999
  },
1088
- managed: { written: [], created: [], unchanged: [], skippedMerged: [], skippedPlatform: [], skippedTUI: [], removed: [] },
1000
+ managed: {
1001
+ written: [],
1002
+ created: [],
1003
+ unchanged: [],
1004
+ protected: [],
1005
+ conflicts: [],
1006
+ skippedMerged: [],
1007
+ skippedPlatform: [],
1008
+ skippedTUI: [],
1009
+ removed: []
1010
+ },
1089
1011
  custom: {
1090
1012
  detected: [],
1091
1013
  generated: [],
@@ -1127,6 +1049,22 @@ function syncTemplates(projectRoot, templateRootOverride) {
1127
1049
  const allRels = mergedRels;
1128
1050
  const allSet = new Set(allRels);
1129
1051
 
1052
+ function renderedTemplate(entry) {
1053
+ const target = norm(renderPathname(entry, project));
1054
+ const selected = platformSelect(
1055
+ langSelect(entryVariantRels(entry, allSet, platformType), lang, allSet, project),
1056
+ platformType,
1057
+ project
1058
+ );
1059
+ const src = selected.get(target);
1060
+ if (!src) return null;
1061
+ const srcRoot = sourceMap.get(src) || templateRoot;
1062
+ const srcFull = path.join(srcRoot, src);
1063
+ return isBinary(srcFull)
1064
+ ? fs.readFileSync(srcFull)
1065
+ : renderContent(fs.readFileSync(srcFull, 'utf8'), vars);
1066
+ }
1067
+
1130
1068
  for (const entry of [...managed, ...merged, ...ejected]) {
1131
1069
  if (!isPathOwnedByOtherPlatform(entry, platformType)) continue;
1132
1070
 
@@ -1135,17 +1073,49 @@ function syncTemplates(projectRoot, templateRootOverride) {
1135
1073
  if (!fs.existsSync(dir)) continue;
1136
1074
 
1137
1075
  for (const filePath of walkDir(dir)) {
1076
+ const relativeFile = norm(path.relative(projectRoot, filePath));
1077
+ if (guardedManaged.has(relativeFile)) continue;
1138
1078
  fs.unlinkSync(filePath);
1139
- report.managed.removed.push(norm(path.relative(projectRoot, filePath)));
1079
+ report.managed.removed.push(relativeFile);
1140
1080
  }
1141
1081
  removeEmptyDirs(dir);
1142
1082
  continue;
1143
1083
  }
1144
1084
 
1145
- const target = path.join(projectRoot, renderPathname(entry, project));
1146
- if (!fs.existsSync(target)) continue;
1147
- fs.unlinkSync(target);
1148
- report.managed.removed.push(norm(path.relative(projectRoot, target)));
1085
+ const renderedTarget = norm(renderPathname(entry, project));
1086
+ const target = path.join(projectRoot, renderedTarget);
1087
+ if (!guardedManaged.has(renderedTarget)) {
1088
+ if (!fs.existsSync(target)) continue;
1089
+ fs.unlinkSync(target);
1090
+ report.managed.removed.push(renderedTarget);
1091
+ continue;
1092
+ }
1093
+
1094
+ const baseline = trustedBaseline(managedBaselines[renderedTarget]);
1095
+ const templateContent = renderedTemplate(entry);
1096
+ const templateHash = templateContent === null ? null : sha256(templateContent);
1097
+ const localHash = fs.existsSync(target) ? sha256(fs.readFileSync(target)) : null;
1098
+ const safeToRemove = localHash !== null && (localHash === baseline || (!baseline && localHash === templateHash));
1099
+
1100
+ if (safeToRemove) {
1101
+ fs.unlinkSync(target);
1102
+ report.managed.removed.push(renderedTarget);
1103
+ if (Object.prototype.hasOwnProperty.call(managedBaselines, renderedTarget)) {
1104
+ delete managedBaselines[renderedTarget];
1105
+ baselinesChanged = true;
1106
+ }
1107
+ continue;
1108
+ }
1109
+
1110
+ if (localHash !== null || baseline !== null) {
1111
+ report.managed.conflicts.push({
1112
+ target: renderedTarget,
1113
+ reason: localHash === null ? 'platform-switch-deleted' : 'platform-switch-modified',
1114
+ baseline,
1115
+ local: localHash,
1116
+ template: templateHash
1117
+ });
1118
+ }
1149
1119
  }
1150
1120
 
1151
1121
  // Cleanup files owned by disabled built-in TUIs. Iterates managed + merged
@@ -1230,6 +1200,68 @@ function syncTemplates(projectRoot, templateRootOverride) {
1230
1200
  : renderContent(fs.readFileSync(srcFull, 'utf8'), vars);
1231
1201
 
1232
1202
  const exists = fs.existsSync(dstFull);
1203
+ if (guardedManaged.has(tgt)) {
1204
+ const rawBaseline = managedBaselines[tgt];
1205
+ const baseline = trustedBaseline(rawBaseline);
1206
+ const templateHash = sha256(content);
1207
+ const localHash = exists ? sha256(fs.readFileSync(dstFull)) : null;
1208
+
1209
+ if (rawBaseline !== undefined && baseline === null) {
1210
+ delete managedBaselines[tgt];
1211
+ baselinesChanged = true;
1212
+ }
1213
+
1214
+ if (baseline === null && localHash !== null && localHash !== templateHash) {
1215
+ report.managed.conflicts.push({
1216
+ target: tgt,
1217
+ reason: 'unknown-origin',
1218
+ baseline: null,
1219
+ local: localHash,
1220
+ template: templateHash
1221
+ });
1222
+ continue;
1223
+ }
1224
+
1225
+ if (baseline !== null && templateHash === baseline && localHash !== baseline) {
1226
+ report.managed.protected.push({
1227
+ target: tgt,
1228
+ reason: localHash === null ? 'user-deleted' : 'user-modified',
1229
+ baseline,
1230
+ local: localHash,
1231
+ template: templateHash
1232
+ });
1233
+ continue;
1234
+ }
1235
+
1236
+ if (baseline !== null && localHash !== baseline && templateHash !== baseline && localHash !== templateHash) {
1237
+ report.managed.conflicts.push({
1238
+ target: tgt,
1239
+ reason: 'both-modified',
1240
+ baseline,
1241
+ local: localHash,
1242
+ template: templateHash
1243
+ });
1244
+ continue;
1245
+ }
1246
+
1247
+ if (localHash === templateHash) {
1248
+ report.managed.unchanged.push(tgt);
1249
+ if (managedBaselines[tgt] !== templateHash) {
1250
+ managedBaselines[tgt] = templateHash;
1251
+ baselinesChanged = true;
1252
+ }
1253
+ continue;
1254
+ }
1255
+
1256
+ const dir = path.dirname(dstFull);
1257
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1258
+ fs.writeFileSync(dstFull, content);
1259
+ managedBaselines[tgt] = templateHash;
1260
+ baselinesChanged = true;
1261
+ (exists ? report.managed.written : report.managed.created).push(tgt);
1262
+ continue;
1263
+ }
1264
+
1233
1265
  if (exists) {
1234
1266
  const cur = bin ? fs.readFileSync(dstFull) : fs.readFileSync(dstFull, 'utf8');
1235
1267
  if (bin ? content.equals(cur) : content === cur) {
@@ -1355,10 +1387,16 @@ function syncTemplates(projectRoot, templateRootOverride) {
1355
1387
  cfg.files.managed = managed;
1356
1388
  cfg.files.merged = merged;
1357
1389
  cfg.files.ejected = ejected;
1390
+ if (Object.keys(managedBaselines).length > 0) {
1391
+ cfg.files.managedBaselines = managedBaselines;
1392
+ } else {
1393
+ if (Object.prototype.hasOwnProperty.call(cfg.files, 'managedBaselines')) baselinesChanged = true;
1394
+ delete cfg.files.managedBaselines;
1395
+ }
1358
1396
  cfg.templateVersion = version;
1359
1397
  delete cfg.templateSource;
1360
1398
 
1361
- report.configUpdated = hasChanges || prevVersion !== version || hadTemplateSource || sandboxToolsMigrated;
1399
+ report.configUpdated = hasChanges || baselinesChanged || prevVersion !== version || hadTemplateSource || sandboxToolsMigrated;
1362
1400
 
1363
1401
  fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
1364
1402