magus-cli 7.5.2 → 7.5.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,63 +1,51 @@
1
1
  /**
2
- * Read-only gitignore check for the gitignore manager.
2
+ * The TUI's startup gitignore question.
3
3
  *
4
- * Called from the TUI on startup and from `doctor`. Resolves the manifest, runs
5
- * detection, and returns a concise warning when violations exist. Does NOT
6
- * auto-apply fixes — the user must open the Git State tab and confirm. This
7
- * mirrors the plugin-version-mismatch flow, where the check only *reports* and
8
- * the TUI provides the interactive fix dialog.
4
+ * Asked on every open (and again after a first adoption). Resolves the
5
+ * manifest — including, in a project with profiles, the files magus generates
6
+ * from `.claude/profiles.json` — runs detection, and reports. Nothing here
7
+ * applies a fix: the startup question applies the in-place fix only when the
8
+ * user picks it (`applyStartupFixes`), and every other destructive fix is
9
+ * confirmed per item in the Git State tab.
9
10
  */
10
11
 
11
12
  import type { Violation } from "../types/gitignore.js";
12
- import { detectViolations } from "./gitignore-detector.js";
13
- import { formatViolationSummary } from "./gitignore-fixer.js";
14
- import { resolveGitignore } from "./gitignore-resolver.js";
13
+ import { loadGitignoreState } from "./gitignore-service.js";
15
14
 
16
- export interface GitignoreCheckResult {
15
+ /** What the TUI's startup gitignore question needs to show. */
16
+ export interface StartupGitignoreNeed {
17
17
  violationCount: number;
18
- warnings: string[];
19
- }
20
-
21
- export async function checkGitignore(
22
- cwd: string,
23
- ): Promise<GitignoreCheckResult> {
24
- const warnings: string[] = [];
25
- try {
26
- const resolved = await resolveGitignore(cwd, {
27
- logger: (m) => warnings.push(m),
28
- });
29
- const violations = await detectViolations(cwd, resolved);
30
- return {
31
- violationCount: violations.length,
32
- warnings,
33
- };
34
- } catch (err) {
35
- warnings.push(
36
- `[gitignore] check failed: ${err instanceof Error ? err.message : String(err)}`,
37
- );
38
- return { violationCount: 0, warnings };
39
- }
18
+ /** Missing `.gitignore` entries — the additive fix. */
19
+ safeCount: number;
20
+ /**
21
+ * Files magus generates from `.claude/profiles.json` that git tracks — the
22
+ * ones the startup fix untracks in place.
23
+ */
24
+ generated: string[];
40
25
  }
41
26
 
42
27
  /**
43
- * One-line summary suitable for stderr. Returns null when there's
44
- * nothing to warn about.
28
+ * The startup gitignore question, or null when there is nothing to ask.
29
+ *
30
+ * Asked fresh on every call — the TUI calls it on every open, and again after
31
+ * a first adoption — and nothing remembers an earlier answer: a tracked
32
+ * generated file is asked about until it is fixed.
45
33
  */
46
- export function formatGitignoreWarning(
47
- result: GitignoreCheckResult,
48
- ): string | null {
49
- if (result.violationCount === 0) return null;
50
- return `⚠ ${result.violationCount} git state issue(s) detected. Run: magus → Git State tab to review and fix`;
34
+ export async function startupGitignoreNeed(
35
+ cwd: string,
36
+ ): Promise<StartupGitignoreNeed | null> {
37
+ const { violations } = await loadGitignoreState(cwd);
38
+ if (violations.length === 0) return null;
39
+ return {
40
+ violationCount: violations.length,
41
+ safeCount: violations.filter((v) => v.severity === "safe").length,
42
+ generated: generatedTracked(violations).map((v) => v.path),
43
+ };
51
44
  }
52
45
 
53
- /**
54
- * Verbose multi-line summary, used by the Git State tab when the user wants
55
- * to know what's wrong before deciding what to do.
56
- */
57
- export async function describeViolations(
58
- cwd: string,
59
- violations: Violation[],
60
- ): Promise<string> {
61
- void cwd;
62
- return formatViolationSummary(violations);
46
+ /** The violations the startup fix handles in place: tracked generated files. */
47
+ export function generatedTracked(violations: Violation[]): Violation[] {
48
+ return violations.filter(
49
+ (v) => v.kind === "tracked-but-should-ignore" && v.source === "profile",
50
+ );
63
51
  }
@@ -5,6 +5,10 @@
5
5
  * 1. Built-in defaults (shipped with magus)
6
6
  * 2. Global manifest at ~/.claude/gitignore.json (team-level, manually shared)
7
7
  * 3. Project manifest at <cwd>/.claude/gitignore.json
8
+ * 4. Profile: in a project with profiles, the files magus generates from
9
+ * `.claude/profiles.json` (passed in as `opts.profile`). Highest, because
10
+ * those files are rewritten per developer — tracking one cannot be right
11
+ * whatever another manifest says.
8
12
  *
9
13
  * `extends` references are resolved against BUILTIN_TEMPLATES. Within a single
10
14
  * tier, the same pattern cannot appear in both `ignore` and `track` — that
@@ -33,9 +37,11 @@ export interface ResolveOptions {
33
37
  homeDir?: string;
34
38
  /** Optional logger for non-fatal warnings (malformed JSON, missing template). */
35
39
  logger?: (msg: string) => void;
40
+ /** The profile tier — see `profileGitignoreManifest`. */
41
+ profile?: GitignoreManifest | null;
36
42
  }
37
43
 
38
- const TIER_ORDER: Tier[] = ["builtin", "global", "project"];
44
+ const TIER_ORDER: Tier[] = ["builtin", "global", "project", "profile"];
39
45
 
40
46
  export async function resolveGitignore(
41
47
  projectCwd: string,
@@ -59,6 +65,9 @@ export async function resolveGitignore(
59
65
  const projectManifest = await readManifest(projectPath, log);
60
66
  if (projectManifest) tierManifests.set("project", projectManifest);
61
67
 
68
+ // 4. The files the profile model generates
69
+ if (opts.profile) tierManifests.set("profile", opts.profile);
70
+
62
71
  // Apply `extends` for each tier (templates count as part of their tier)
63
72
  for (const tier of TIER_ORDER) {
64
73
  const m = tierManifests.get(tier);
@@ -28,6 +28,7 @@ import {
28
28
  } from "./gitignore-fixer.js";
29
29
  import { resolveGitignore } from "./gitignore-resolver.js";
30
30
  import { readSettingsFile, writableList } from "./settings-file.js";
31
+ import { profileGitignoreManifest } from "./tracked-live-files.js";
31
32
 
32
33
  export interface GitignoreState {
33
34
  resolved: ResolvedManifest;
@@ -40,11 +41,77 @@ export async function loadGitignoreState(cwd: string): Promise<GitignoreState> {
40
41
  const warnings: string[] = [];
41
42
  const resolved = await resolveGitignore(cwd, {
42
43
  logger: (m) => warnings.push(m),
44
+ profile: await profileGitignoreManifest(cwd),
43
45
  });
44
46
  const violations = await detectViolations(cwd, resolved);
45
47
  return { resolved, violations, warnings };
46
48
  }
47
49
 
50
+ export interface StartupFixResult {
51
+ /** Generated files removed from the index — staged, not committed. */
52
+ untracked: string[];
53
+ /** `.gitignore` entries added. */
54
+ appended: string[];
55
+ /** Files that could not be untracked, with git's reason. */
56
+ failed: Array<{ path: string; reason: string }>;
57
+ }
58
+
59
+ /**
60
+ * The startup question's fix, applied in place: every file magus generates
61
+ * from `.claude/profiles.json` that git tracks is removed from the index
62
+ * (`git rm --cached`, the existing tracked-but-should-ignore fix, which also
63
+ * makes sure `.gitignore` ignores it), and the additive `.gitignore` fixes
64
+ * are applied too. Every other violation is left for the Git State tab.
65
+ */
66
+ export async function applyStartupFixes(
67
+ cwd: string,
68
+ ): Promise<StartupFixResult> {
69
+ const { resolved, violations } = await loadGitignoreState(cwd);
70
+ const generatedRules = resolved.rules
71
+ .filter((r) => r.source === "profile" && r.action === "ignore")
72
+ .map((r) => r.pattern);
73
+ const result: StartupFixResult = { untracked: [], appended: [], failed: [] };
74
+ for (const v of violations) {
75
+ if (v.kind !== "tracked-but-should-ignore" || v.source !== "profile") {
76
+ continue;
77
+ }
78
+ const rule =
79
+ generatedRules.find(
80
+ (p) => p === v.path || (p.endsWith("/") && v.path.startsWith(p)),
81
+ ) ?? v.path;
82
+ try {
83
+ await coreApplyDestructiveFix(cwd, v, rule);
84
+ result.untracked.push(v.path);
85
+ } catch (error) {
86
+ result.failed.push({
87
+ path: v.path,
88
+ reason: error instanceof Error ? error.message : String(error),
89
+ });
90
+ }
91
+ }
92
+ result.appended = (await coreApplySafeFixes(cwd, violations)).appended;
93
+ return result;
94
+ }
95
+
96
+ /** What {@link applyStartupFixes} did, for the message the TUI shows. */
97
+ export function describeStartupFix(result: StartupFixResult): string {
98
+ const lines: string[] = [];
99
+ if (result.untracked.length > 0) {
100
+ lines.push(
101
+ "Stopped tracking (the removal is staged — commit it):",
102
+ ...result.untracked.map((p) => ` ${p}`),
103
+ ' git commit -m "Stop tracking generated Claude Code config"',
104
+ );
105
+ }
106
+ if (result.appended.length > 0) {
107
+ lines.push("Added to .gitignore:", ...result.appended.map((p) => ` ${p}`));
108
+ }
109
+ for (const f of result.failed) {
110
+ lines.push(`Could not untrack ${f.path}: ${f.reason}`);
111
+ }
112
+ return lines.length > 0 ? lines.join("\n") : "Nothing needed changing.";
113
+ }
114
+
48
115
  export async function applyAllSafeFixes(
49
116
  cwd: string,
50
117
  violations: Violation[],
@@ -61,8 +61,7 @@ import {
61
61
  type ActivateOptions,
62
62
  activateProfile,
63
63
  activationNotes,
64
- activeWritableSkillFolders,
65
- writeRefusal,
64
+ releasedCommittedSkillNote,
66
65
  } from "./profile-activate.js";
67
66
  import {
68
67
  type ActiveMarker,
@@ -94,6 +93,7 @@ import {
94
93
  } from "./profile-skills.js";
95
94
  import { resolveExtends, resolveProfile } from "./resolver.js";
96
95
  import { SettingsParseError } from "./settings-file.js";
96
+ import { trackedSkillFolders } from "./tracked-live-files.js";
97
97
 
98
98
  // ─── Changes ─────────────────────────────────────────────────────────────────
99
99
 
@@ -1302,13 +1302,39 @@ export async function reconcileActiveProfile(
1302
1302
  opts: ReconcileOptions = {},
1303
1303
  ): Promise<ReconcileResult> {
1304
1304
  if (!(await hasCurrentMarker(projectPath))) return emptyResult(null);
1305
- const marker = await readActiveMarker(projectPath);
1305
+ let marker = await readActiveMarker(projectPath);
1306
1306
  if (!marker) return emptyResult(null);
1307
1307
  const manifest = await readManifest(projectPath);
1308
1308
  const current = manifest.profiles[marker.profile];
1309
1309
  if (!current) return emptyResult(null);
1310
1310
  const name = marker.profile;
1311
1311
 
1312
+ // A recorded skill folder git tracks is the team's copy: 7.5.0's conversion
1313
+ // recorded committed skills as magus-installed. Handed back — dropped from
1314
+ // the marker, and from magus's ignore lines — before anything below could
1315
+ // treat it as magus's to sync or remove, and said once.
1316
+ const handedBackLines: string[] = [];
1317
+ const handedBack = await trackedSkillFolders(
1318
+ projectPath,
1319
+ Object.keys(marker.skills),
1320
+ );
1321
+ if (handedBack.size > 0) {
1322
+ const skills: Record<string, string> = {};
1323
+ for (const [folder, hash] of Object.entries(marker.skills)) {
1324
+ if (!handedBack.has(folder)) skills[folder] = hash;
1325
+ }
1326
+ await updateSkillIgnoreLines(
1327
+ projectPath,
1328
+ [...handedBack],
1329
+ Object.keys(skills),
1330
+ );
1331
+ marker = { ...marker, skills };
1332
+ await writeActiveMarker(marker, projectPath);
1333
+ handedBackLines.push(
1334
+ ...[...handedBack].sort().map(releasedCommittedSkillNote),
1335
+ );
1336
+ }
1337
+
1312
1338
  const edited: LiveFileKey[] = [];
1313
1339
  const missing: LiveFileKey[] = [];
1314
1340
  for (const key of LIVE_FILE_KEYS) {
@@ -1343,23 +1369,11 @@ export async function reconcileActiveProfile(
1343
1369
  !definitionChanged &&
1344
1370
  !marker.cliBookkeeping
1345
1371
  ) {
1346
- return emptyResult(name);
1372
+ return { ...emptyResult(name), lines: handedBackLines };
1347
1373
  }
1348
1374
 
1349
1375
  const result = emptyResult(name);
1350
-
1351
- // Every write below lands in a live file, a skill folder the profile
1352
- // installs (or the manifest, on the way to one). None happens when git
1353
- // tracks one of them, or cannot say: see `tracked-live-files.ts`.
1354
- const refusal = await writeRefusal(
1355
- projectPath,
1356
- await activeWritableSkillFolders(projectPath, manifest),
1357
- );
1358
- if (refusal) {
1359
- result.problems.push(refusal.message);
1360
- result.lines.push(refusal.message);
1361
- return result;
1362
- }
1376
+ result.lines.push(...handedBackLines);
1363
1377
 
1364
1378
  // ── a. absorb ────────────────────────────────────────────────────────────
1365
1379
  const base = marker.base ?? current;
@@ -75,10 +75,10 @@ import {
75
75
  updateSkillIgnoreLines,
76
76
  } from "./profile-skills.js";
77
77
  import {
78
- LiveFileRefusal,
79
- TrackedLiveFileError,
78
+ GitCheckError,
80
79
  trackedPaths,
81
- untrackCommand,
80
+ trackedSkillFolders,
81
+ trackedWarning,
82
82
  } from "./tracked-live-files.js";
83
83
  import type { ApplyReporter } from "./update-engine.js";
84
84
 
@@ -91,8 +91,12 @@ export interface ActivationResult {
91
91
  skills: SkillSyncResult;
92
92
  /** What converting an older layout did, if there was one. */
93
93
  conversion: LegacySkillConversion;
94
- /** Generated paths git still tracks (see {@link trackedGeneratedFiles}). */
95
- tracked: string[];
94
+ /**
95
+ * Generated paths git still tracks (see {@link trackedGeneratedFiles}) —
96
+ * written all the same, and each warned about. A string instead when git
97
+ * could not say (`GitCheckError`).
98
+ */
99
+ tracked: string[] | string;
96
100
  /** Where `.claude/_profiles/` was kept, project-relative, or null. */
97
101
  keptAt: string | null;
98
102
  /** Unsaved changes added to a profile before activating, one line each. */
@@ -101,6 +105,11 @@ export interface ActivationResult {
101
105
  dependencies?: DependencyReport;
102
106
  /** Listed skills whose folder the team committed: that copy is used. */
103
107
  committedSkills?: string[];
108
+ /**
109
+ * Skill folders the marker recorded as magus-installed that git tracks —
110
+ * handed back to the team (see `releasedCommittedSkillNote`).
111
+ */
112
+ releasedCommitted?: string[];
104
113
  }
105
114
 
106
115
  export interface ActivateOptions {
@@ -123,20 +132,19 @@ export function reportDisplaced(
123
132
  }
124
133
 
125
134
  /**
126
- * Generated paths that git tracks: the three live files, a committed
127
- * `.claude/skills` SYMLINK (an older layout committed by mistake), and any of
128
- * `skillFolders` — skill folders magus would install or remove. Committed
129
- * custom skills under a real `.claude/skills/` directory are not generated
130
- * state and are never reported. Empty outside a git repository.
135
+ * Generated files that git tracks: the three live files, and a committed
136
+ * `.claude/skills` SYMLINK (an older layout committed by mistake). A skill
137
+ * FOLDER git tracks is never one of them — it is the team's copy (see
138
+ * `trackedSkillFolders`). Empty outside a git repository; throws
139
+ * `GitCheckError` when git cannot say. Reported only — nothing refuses to
140
+ * write because of it (see `tracked-live-files.ts`).
131
141
  */
132
- export async function trackedWriteTargets(
142
+ export async function trackedGeneratedFiles(
133
143
  projectPath: string,
134
- skillFolders: readonly string[] = [],
135
144
  ): Promise<string[]> {
136
145
  const rel = (abs: string) => path.relative(projectPath, abs);
137
146
  const live = LIVE_FILE_KEYS.map((key) => rel(livePath(key, projectPath)));
138
147
  const skills = rel(skillsRoot(projectPath));
139
- const folders = skillFolders.map((folder) => path.join(skills, folder));
140
148
  let skillsIsLink = false;
141
149
  try {
142
150
  skillsIsLink = (await lstat(skillsRoot(projectPath))).isSymbolicLink();
@@ -144,112 +152,44 @@ export async function trackedWriteTargets(
144
152
  // No .claude/skills at all.
145
153
  }
146
154
  const files = [...live, ...(skillsIsLink ? [skills] : [])];
147
- const tracked = await trackedPaths(projectPath, files, folders);
148
- return [...files, ...folders].filter((c) => tracked.has(c));
149
- }
150
-
151
- /**
152
- * The folders of `skills` the team committed under `.claude/skills/`. Such a
153
- * folder is the team's copy of the skill: magus neither installs over it nor
154
- * manages it, so it never has cause to suggest untracking it.
155
- */
156
- export async function committedSkillFolders(
157
- projectPath: string,
158
- skills: readonly ProfileSkillRef[],
159
- ): Promise<Set<string>> {
160
- const root = path.relative(projectPath, skillsRoot(projectPath));
161
- const folders = skills.map((ref) => skillFolder(ref));
162
- const tracked = await trackedPaths(
163
- projectPath,
164
- [],
165
- folders.map((folder) => path.join(root, folder)),
166
- );
167
- return new Set(folders.filter((f) => tracked.has(path.join(root, f))));
168
- }
169
-
170
- /** {@link trackedWriteTargets} for the live files alone — what doctor reports. */
171
- export async function trackedGeneratedFiles(
172
- projectPath: string,
173
- ): Promise<string[]> {
174
- return trackedWriteTargets(projectPath);
155
+ const tracked = await trackedPaths(projectPath, files);
156
+ return files.filter((c) => tracked.has(c));
175
157
  }
176
158
 
177
159
  /**
178
- * The skill folders activating a profile with `skills` could write: those
179
- * magus manages now (kept or removed), and the profile's that are not on disk
180
- * yet — unless the team committed them (see {@link committedSkillFolders}).
181
- * An existing folder magus does not manage is a custom skill, which
182
- * activation refuses as a clash rather than writing.
160
+ * {@link trackedGeneratedFiles} as the warning lines the CLI prints: one per
161
+ * tracked path, or one saying git could not be asked.
183
162
  */
184
- export async function writableSkillFolders(
163
+ export async function trackedGeneratedWarnings(
185
164
  projectPath: string,
186
- skills: readonly ProfileSkillRef[],
187
- managed: Readonly<Record<string, string>> = {},
188
165
  ): Promise<string[]> {
189
- const out = new Set(Object.keys(managed));
190
- const missing: ProfileSkillRef[] = [];
191
- for (const ref of skills) {
192
- const folder = skillFolder(ref);
193
- if (out.has(folder)) continue;
194
- try {
195
- await lstat(path.join(skillsRoot(projectPath), folder));
196
- } catch {
197
- missing.push(ref);
198
- }
199
- }
200
- const committed = await committedSkillFolders(projectPath, missing);
201
- for (const ref of missing) {
202
- if (!committed.has(skillFolder(ref))) out.add(skillFolder(ref));
166
+ try {
167
+ return (await trackedGeneratedFiles(projectPath)).map(trackedWarning);
168
+ } catch (error) {
169
+ if (error instanceof GitCheckError) return [error.message];
170
+ throw error;
203
171
  }
204
- return [...out];
205
172
  }
206
173
 
207
174
  /**
208
- * The skill folders the ACTIVE profile's next render could write — see
209
- * {@link writableSkillFolders}. For the checks made before a reconcile, which
210
- * may re-render and so install or remove them.
175
+ * The folders of `skills` the team committed under `.claude/skills/`. Such a
176
+ * folder is the team's copy of the skill: magus neither installs over it nor
177
+ * manages it, so it never has cause to suggest untracking it. None when git
178
+ * cannot say — each existing folder is then judged as a custom skill.
211
179
  */
212
- export async function activeWritableSkillFolders(
180
+ export async function committedSkillFolders(
213
181
  projectPath: string,
214
- manifest: ProfileManifest,
215
- ): Promise<string[]> {
216
- const marker = await readActiveMarker(projectPath);
217
- if (!marker) return [];
218
- return writableSkillFolders(
182
+ skills: readonly ProfileSkillRef[],
183
+ ): Promise<Set<string>> {
184
+ return trackedSkillFolders(
219
185
  projectPath,
220
- manifest.profiles[marker.profile]?.skills ?? [],
221
- marker.skills,
186
+ skills.map((ref) => skillFolder(ref)),
222
187
  );
223
188
  }
224
189
 
225
- /**
226
- * Why magus must not write here, or null: {@link assertNoTrackedWriteTargets}
227
- * as a value, for callers that report a refusal instead of throwing it.
228
- */
229
- export async function writeRefusal(
230
- projectPath: string,
231
- skillFolders: readonly string[] = [],
232
- ): Promise<LiveFileRefusal | null> {
233
- try {
234
- await assertNoTrackedWriteTargets(projectPath, skillFolders);
235
- return null;
236
- } catch (error) {
237
- if (error instanceof LiveFileRefusal) return error;
238
- throw error;
239
- }
240
- }
241
-
242
- /**
243
- * Throw {@link TrackedLiveFileError} — before anything is written — when any
244
- * of {@link trackedWriteTargets} is tracked, or `GitCheckError` when git
245
- * cannot say.
246
- */
247
- export async function assertNoTrackedWriteTargets(
248
- projectPath: string,
249
- skillFolders: readonly string[] = [],
250
- ): Promise<void> {
251
- const tracked = await trackedWriteTargets(projectPath, skillFolders);
252
- if (tracked.length > 0) throw new TrackedLiveFileError(tracked);
190
+ /** The line for a recorded skill folder handed back to the team. */
191
+ export function releasedCommittedSkillNote(folder: string): string {
192
+ return `Skill ${folder} is committed by the team — magus no longer manages it`;
253
193
  }
254
194
 
255
195
  /**
@@ -274,16 +214,16 @@ export function activationNotes(result: ActivationResult): string[] {
274
214
  for (const f of result.skills.failed) {
275
215
  lines.push(`Skill "${f.skill}" could not be installed: ${f.reason}`);
276
216
  }
217
+ for (const folder of result.releasedCommitted ?? []) {
218
+ lines.push(releasedCommittedSkillNote(folder));
219
+ }
277
220
  for (const folder of result.committedSkills ?? []) {
278
221
  lines.push(
279
222
  `Skill .claude/skills/${folder} is committed to git — the team's copy is used; magus does not install or manage it`,
280
223
  );
281
224
  }
282
- for (const rel of result.tracked) {
283
- lines.push(
284
- `${rel} is generated but tracked by git — run \`${untrackCommand(rel)}\`, then commit`,
285
- );
286
- }
225
+ if (typeof result.tracked === "string") lines.push(result.tracked);
226
+ else lines.push(...result.tracked.map(trackedWarning));
287
227
  if (result.keptAt) {
288
228
  lines.push(
289
229
  `The older layout's .claude/_profiles/ was kept at ${result.keptAt}/`,
@@ -295,9 +235,10 @@ export function activationNotes(result: ActivationResult): string[] {
295
235
  /**
296
236
  * Render `profileId` from `manifest` and make it the active profile.
297
237
  *
298
- * Throws {@link TrackedLiveFileError}, having changed nothing, when a path it
299
- * would write is tracked by git. Throws `SettingsParseError`, having changed
300
- * nothing, when a live file of a managed project does not parse. Throws
238
+ * A generated file git tracks is written like any other, and named in
239
+ * `tracked` so the caller can warn about it. Throws `SettingsParseError`,
240
+ * having changed nothing, when a live file of a managed project does not
241
+ * parse. Throws
301
242
  * {@link SkillClashError} — having
302
243
  * changed nothing but moving an older layout's skills into `.claude/skills/` —
303
244
  * when a listed skill's folder is already a custom skill.
@@ -315,21 +256,27 @@ export async function activateProfile(
315
256
  // Read before converting: the older marker lives in the directory the
316
257
  // conversion moves away, and an older layout is recognised by its links.
317
258
  const previous = await readActiveMarker(projectPath);
318
- // Before anything else is touched — the conversion below writes too.
319
- await assertNoTrackedWriteTargets(
320
- projectPath,
321
- await writableSkillFolders(projectPath, skillRefs, previous?.skills),
322
- );
323
259
  const wasOldLayout = (await oldLayoutProfile(projectPath)) !== null;
324
260
  // A managed project's live files are the profile's; one that does not
325
261
  // parse is somebody mid-edit, and replacing it would lose their work. (At
326
262
  // first adoption a hand-written file is moved to a backup, not replaced.)
327
263
  if (previous !== null) await assertLiveFilesParse(projectPath);
328
264
  const conversion = await convertLegacySkills(manifest, projectPath);
329
- const managedBefore: Record<string, string> = {
265
+ const recorded: Record<string, string> = {
330
266
  ...(previous?.skills ?? {}),
331
267
  ...conversion.managed,
332
268
  };
269
+ // A recorded folder git tracks is the team's copy — 7.5.0's conversion
270
+ // recorded committed skills as magus-installed. Dropped from the record
271
+ // here, before a sync could remove it, and said once.
272
+ const committedRecorded = await trackedSkillFolders(
273
+ projectPath,
274
+ Object.keys(recorded),
275
+ );
276
+ const managedBefore: Record<string, string> = {};
277
+ for (const [folder, hash] of Object.entries(recorded)) {
278
+ if (!committedRecorded.has(folder)) managedBefore[folder] = hash;
279
+ }
333
280
 
334
281
  // Listed skills the team committed and magus does not manage: that copy
335
282
  // stands, and magus neither installs over it nor reports it as a clash.
@@ -397,7 +344,7 @@ export async function activateProfile(
397
344
  );
398
345
  await updateSkillIgnoreLines(
399
346
  projectPath,
400
- Object.keys(managedBefore),
347
+ Object.keys(recorded),
401
348
  Object.keys(skills.skills),
402
349
  );
403
350
  await writeActiveMarker(
@@ -412,7 +359,12 @@ export async function activateProfile(
412
359
  displaced,
413
360
  skills,
414
361
  conversion,
415
- tracked: await trackedGeneratedFiles(projectPath),
362
+ tracked: await trackedGeneratedFiles(projectPath).catch(
363
+ (error: unknown) => {
364
+ if (error instanceof GitCheckError) return error.message;
365
+ throw error;
366
+ },
367
+ ),
416
368
  keptAt: conversion.keptAt
417
369
  ? path.relative(projectPath, conversion.keptAt)
418
370
  : null,
@@ -420,6 +372,9 @@ export async function activateProfile(
420
372
  ...(committedSkills.size > 0
421
373
  ? { committedSkills: [...committedSkills].sort() }
422
374
  : {}),
375
+ ...(committedRecorded.size > 0
376
+ ? { releasedCommitted: [...committedRecorded].sort() }
377
+ : {}),
423
378
  };
424
379
  }
425
380
 
@@ -445,7 +400,6 @@ export async function regenerateLiveFiles(
445
400
  const marker = await readActiveMarker(projectPath);
446
401
  const manifest = await readManifest(projectPath);
447
402
  if (!marker || !manifest.profiles[marker.profile]) return false;
448
- await assertNoTrackedWriteTargets(projectPath);
449
403
  await assertLiveFilesParse(projectPath);
450
404
  const rendered = await renderProfile(manifest, marker.profile, projectPath);
451
405
  const files = await writeRenderedFiles(rendered, projectPath);