@m16khb/llm-wiki 0.1.0 → 0.1.2

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.
@@ -0,0 +1,569 @@
1
+ package wiki
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "sort"
9
+ "strings"
10
+ )
11
+
12
+ const cleanupPlanVersion = 1
13
+
14
+ // CleanupAnalyzeOptions controls dry-run candidate generation for
15
+ // LLM-reviewed cleanup planning. AnalyzeCleanup never writes to the vault.
16
+ type CleanupAnalyzeOptions struct {
17
+ Scope string `json:"scope"`
18
+ ArchiveRoot string `json:"archive_root,omitempty"`
19
+ }
20
+
21
+ // CleanupAnalysis is the review packet an agent can inspect before writing a
22
+ // deterministic CleanupPlan.
23
+ type CleanupAnalysis struct {
24
+ Date string `json:"date"`
25
+ Scope string `json:"scope"`
26
+ CandidateCount int `json:"candidate_count"`
27
+ Candidates []CleanupCandidate `json:"candidates"`
28
+ AllowedActions []string `json:"allowed_actions"`
29
+ PlanSchema CleanupPlan `json:"plan_schema"`
30
+ LLMInstructions []string `json:"llm_instructions"`
31
+ Warnings []string `json:"warnings,omitempty"`
32
+ ProtectedRoots []string `json:"protected_roots"`
33
+ DestructiveGuard string `json:"destructive_guard"`
34
+ }
35
+
36
+ // CleanupCandidate is one possible cleanup target surfaced for human/LLM
37
+ // judgment.
38
+ type CleanupCandidate struct {
39
+ ID string `json:"id"`
40
+ Kind string `json:"kind"`
41
+ Title string `json:"title"`
42
+ Reason string `json:"reason"`
43
+ Confidence string `json:"confidence"`
44
+ Files []CleanupCandidateFile `json:"files"`
45
+ RecommendedAction CleanupPlanAction `json:"recommended_action"`
46
+ Warnings []string `json:"warnings,omitempty"`
47
+ }
48
+
49
+ // CleanupCandidateFile describes a vault file involved in a candidate.
50
+ type CleanupCandidateFile struct {
51
+ Path string `json:"path"`
52
+ Role string `json:"role"`
53
+ TargetPath string `json:"target_path,omitempty"`
54
+ }
55
+
56
+ // CleanupPlan is the LLM-authored, deterministic cleanup plan schema accepted
57
+ // by ApplyCleanupPlan. The executor validates every action before writing.
58
+ type CleanupPlan struct {
59
+ Version int `json:"version"`
60
+ Scope string `json:"scope"`
61
+ Summary string `json:"summary,omitempty"`
62
+ Actions []CleanupPlanAction `json:"actions"`
63
+ }
64
+
65
+ // CleanupPlanAction is one allowlisted action. Supported Type values:
66
+ // "run_scope" and "rename_page".
67
+ type CleanupPlanAction struct {
68
+ Type string `json:"type"`
69
+ Scope string `json:"scope,omitempty"`
70
+ ArchiveRoot string `json:"archive_root,omitempty"`
71
+ FromPath string `json:"from_path,omitempty"`
72
+ ToPath string `json:"to_path,omitempty"`
73
+ CanonicalPath string `json:"canonical_path,omitempty"`
74
+ Slug string `json:"slug,omitempty"`
75
+ Reason string `json:"reason,omitempty"`
76
+ }
77
+
78
+ // CleanupPlanApplyOptions controls deterministic plan validation/application.
79
+ // The default is dry-run; callers must set Apply to write.
80
+ type CleanupPlanApplyOptions struct {
81
+ Apply bool `json:"apply"`
82
+ Plan CleanupPlan `json:"plan"`
83
+ }
84
+
85
+ // CleanupPlanApplyResult summarizes a plan validation or application.
86
+ type CleanupPlanApplyResult struct {
87
+ Applied bool `json:"applied"`
88
+ Date string `json:"date"`
89
+ Scope string `json:"scope"`
90
+ Summary string `json:"summary,omitempty"`
91
+ Actions []CleanupPlanActionResult `json:"actions"`
92
+ ReportPaths []string `json:"report_paths,omitempty"`
93
+ Warnings []string `json:"warnings,omitempty"`
94
+ }
95
+
96
+ // CleanupPlanActionResult summarizes one action's validated/applied effects.
97
+ type CleanupPlanActionResult struct {
98
+ Type string `json:"type"`
99
+ Scope string `json:"scope,omitempty"`
100
+ FromPath string `json:"from_path,omitempty"`
101
+ ToPath string `json:"to_path,omitempty"`
102
+ Applied bool `json:"applied"`
103
+ PlannedChanges int `json:"planned_changes"`
104
+ Reason string `json:"reason,omitempty"`
105
+ ReportPaths []string `json:"report_paths,omitempty"`
106
+ Warnings []string `json:"warnings,omitempty"`
107
+ }
108
+
109
+ // AnalyzeCleanup returns an LLM-review packet for cleanup candidates without
110
+ // modifying the vault.
111
+ func (v *Vault) AnalyzeCleanup(opts CleanupAnalyzeOptions) (CleanupAnalysis, error) {
112
+ scope := cleanupScopeOrDefault(opts.Scope)
113
+ analysis := CleanupAnalysis{
114
+ Date: v.nowDate(),
115
+ Scope: scope,
116
+ AllowedActions: []string{"run_scope", "rename_page"},
117
+ PlanSchema: cleanupPlanSchema(scope, opts.ArchiveRoot),
118
+ LLMInstructions: cleanupLLMInstructions(),
119
+ ProtectedRoots: []string{".obsidian/", "10-sources/"},
120
+ DestructiveGuard: "analysis-only; no vault writes occur unless a validated plan is later run with --apply",
121
+ }
122
+ switch scope {
123
+ case "repository-references":
124
+ plan, err := v.planRepositoryReferenceCleanup(RepositoryReferenceCleanupOptions{ArchiveRoot: opts.ArchiveRoot})
125
+ if err != nil {
126
+ return CleanupAnalysis{}, err
127
+ }
128
+ analysis.Warnings = append(analysis.Warnings, plan.result.Warnings...)
129
+ analysis.Candidates = repositoryReferenceCleanupCandidates(plan)
130
+ case "duplicate-slugs":
131
+ plan, err := v.planDuplicateSlugCleanup(DuplicateSlugCleanupOptions{})
132
+ if err != nil {
133
+ return CleanupAnalysis{}, err
134
+ }
135
+ analysis.Warnings = append(analysis.Warnings, plan.result.Warnings...)
136
+ analysis.Candidates = duplicateSlugCleanupCandidates(plan)
137
+ default:
138
+ return CleanupAnalysis{}, fmt.Errorf("unsupported cleanup scope: %s", scope)
139
+ }
140
+ analysis.CandidateCount = len(analysis.Candidates)
141
+ return analysis, nil
142
+ }
143
+
144
+ // ApplyCleanupPlan validates every action, then optionally applies the plan.
145
+ // Dry-run is the default and performs no writes.
146
+ func (v *Vault) ApplyCleanupPlan(opts CleanupPlanApplyOptions) (CleanupPlanApplyResult, error) {
147
+ plan := normalizeCleanupPlan(opts.Plan)
148
+ if plan.Version != cleanupPlanVersion {
149
+ return CleanupPlanApplyResult{}, fmt.Errorf("unsupported cleanup plan version: %d", plan.Version)
150
+ }
151
+ if len(plan.Actions) == 0 {
152
+ return CleanupPlanApplyResult{}, errors.New("cleanup plan has no actions")
153
+ }
154
+ if err := validateCleanupPlanShape(plan); err != nil {
155
+ return CleanupPlanApplyResult{}, err
156
+ }
157
+ result := CleanupPlanApplyResult{
158
+ Applied: opts.Apply,
159
+ Date: v.nowDate(),
160
+ Scope: cleanupScopeOrDefault(plan.Scope),
161
+ Summary: plan.Summary,
162
+ }
163
+
164
+ validated := make([]cleanupPlanValidatedAction, 0, len(plan.Actions))
165
+ for _, action := range plan.Actions {
166
+ item, err := v.validateCleanupPlanAction(action)
167
+ if err != nil {
168
+ return result, err
169
+ }
170
+ validated = append(validated, item)
171
+ }
172
+ for _, item := range validated {
173
+ actionResult, err := v.applyCleanupPlanAction(item, opts.Apply)
174
+ if err != nil {
175
+ return result, err
176
+ }
177
+ result.Actions = append(result.Actions, actionResult)
178
+ result.Warnings = append(result.Warnings, actionResult.Warnings...)
179
+ result.ReportPaths = append(result.ReportPaths, actionResult.ReportPaths...)
180
+ }
181
+ sort.Strings(result.ReportPaths)
182
+ sort.Strings(result.Warnings)
183
+ if opts.Apply {
184
+ if err := v.writeCleanupPlanApplyReport(&result, plan); err != nil {
185
+ return result, err
186
+ }
187
+ }
188
+ return result, nil
189
+ }
190
+
191
+ func validateCleanupPlanShape(plan CleanupPlan) error {
192
+ hasRunScope := false
193
+ for _, action := range plan.Actions {
194
+ if action.Type == "run_scope" {
195
+ hasRunScope = true
196
+ break
197
+ }
198
+ }
199
+ if hasRunScope && len(plan.Actions) > 1 {
200
+ return errors.New("run_scope cleanup plan actions must be the only action in the plan")
201
+ }
202
+ return nil
203
+ }
204
+
205
+ type cleanupPlanValidatedAction struct {
206
+ action CleanupPlanAction
207
+ fromAbs string
208
+ toAbs string
209
+ plannedChanges int
210
+ warnings []string
211
+ }
212
+
213
+ func (v *Vault) validateCleanupPlanAction(action CleanupPlanAction) (cleanupPlanValidatedAction, error) {
214
+ action = normalizeCleanupPlanAction(action)
215
+ item := cleanupPlanValidatedAction{action: action}
216
+ switch action.Type {
217
+ case "run_scope":
218
+ switch action.Scope {
219
+ case "repository-references":
220
+ plan, err := v.planRepositoryReferenceCleanup(RepositoryReferenceCleanupOptions{ArchiveRoot: action.ArchiveRoot})
221
+ if err != nil {
222
+ return item, err
223
+ }
224
+ item.plannedChanges = len(plan.result.Bundles)
225
+ item.warnings = append(item.warnings, plan.result.Warnings...)
226
+ case "duplicate-slugs":
227
+ plan, err := v.planDuplicateSlugCleanup(DuplicateSlugCleanupOptions{})
228
+ if err != nil {
229
+ return item, err
230
+ }
231
+ item.plannedChanges = len(plan.result.Renames)
232
+ item.warnings = append(item.warnings, plan.result.Warnings...)
233
+ default:
234
+ return item, fmt.Errorf("unsupported cleanup run_scope scope: %s", action.Scope)
235
+ }
236
+ case "rename_page":
237
+ fromAbs, toAbs, err := v.validateCleanupPlanRename(action)
238
+ if err != nil {
239
+ return item, err
240
+ }
241
+ item.fromAbs = fromAbs
242
+ item.toAbs = toAbs
243
+ item.plannedChanges = 1
244
+ default:
245
+ return item, fmt.Errorf("unsupported cleanup plan action type: %s", action.Type)
246
+ }
247
+ return item, nil
248
+ }
249
+
250
+ func (v *Vault) applyCleanupPlanAction(item cleanupPlanValidatedAction, apply bool) (CleanupPlanActionResult, error) {
251
+ action := item.action
252
+ result := CleanupPlanActionResult{
253
+ Type: action.Type,
254
+ Scope: action.Scope,
255
+ FromPath: action.FromPath,
256
+ ToPath: action.ToPath,
257
+ Applied: apply,
258
+ PlannedChanges: item.plannedChanges,
259
+ Reason: action.Reason,
260
+ Warnings: append([]string(nil), item.warnings...),
261
+ }
262
+ switch action.Type {
263
+ case "run_scope":
264
+ switch action.Scope {
265
+ case "repository-references":
266
+ scopeResult, err := v.CleanupRepositoryReferences(RepositoryReferenceCleanupOptions{Apply: apply, ArchiveRoot: action.ArchiveRoot})
267
+ if err != nil {
268
+ return result, err
269
+ }
270
+ result.PlannedChanges = len(scopeResult.Bundles)
271
+ result.ReportPaths = append(result.ReportPaths, scopeResult.ReportPaths...)
272
+ result.Warnings = append(result.Warnings, scopeResult.Warnings...)
273
+ case "duplicate-slugs":
274
+ scopeResult, err := v.CleanupDuplicateSlugs(DuplicateSlugCleanupOptions{Apply: apply})
275
+ if err != nil {
276
+ return result, err
277
+ }
278
+ result.PlannedChanges = len(scopeResult.Renames)
279
+ result.ReportPaths = append(result.ReportPaths, scopeResult.ReportPaths...)
280
+ result.Warnings = append(result.Warnings, scopeResult.Warnings...)
281
+ }
282
+ case "rename_page":
283
+ if apply {
284
+ if err := v.applyCleanupPlanRename(action, item.fromAbs, item.toAbs); err != nil {
285
+ return result, err
286
+ }
287
+ }
288
+ }
289
+ sort.Strings(result.ReportPaths)
290
+ sort.Strings(result.Warnings)
291
+ return result, nil
292
+ }
293
+
294
+ func (v *Vault) validateCleanupPlanRename(action CleanupPlanAction) (string, string, error) {
295
+ fromPath := action.FromPath
296
+ toPath := action.ToPath
297
+ if fromPath == "" || toPath == "" {
298
+ return "", "", errors.New("rename_page requires from_path and to_path")
299
+ }
300
+ if !strings.EqualFold(filepath.Ext(fromPath), ".md") || !strings.EqualFold(filepath.Ext(toPath), ".md") {
301
+ return "", "", errors.New("rename_page paths must be markdown files")
302
+ }
303
+ if isCleanupPlanProtectedPath(fromPath) {
304
+ return "", "", fmt.Errorf("rename_page source is protected: %s", fromPath)
305
+ }
306
+ if isCleanupPlanProtectedPath(toPath) {
307
+ return "", "", fmt.Errorf("rename_page target is protected: %s", toPath)
308
+ }
309
+ if fromPath == toPath {
310
+ return "", "", fmt.Errorf("rename_page source and target are identical: %s", fromPath)
311
+ }
312
+ fromAbs, err := v.SafeJoin(fromPath)
313
+ if err != nil {
314
+ return "", "", err
315
+ }
316
+ toAbs, err := v.SafeJoin(toPath)
317
+ if err != nil {
318
+ return "", "", err
319
+ }
320
+ if _, err := os.Stat(fromAbs); err != nil {
321
+ return "", "", err
322
+ }
323
+ if _, err := os.Stat(toAbs); err == nil {
324
+ return "", "", fmt.Errorf("rename_page target already exists: %s", toPath)
325
+ } else if !errors.Is(err, os.ErrNotExist) {
326
+ return "", "", err
327
+ }
328
+ return fromAbs, toAbs, nil
329
+ }
330
+
331
+ func (v *Vault) applyCleanupPlanRename(action CleanupPlanAction, fromAbs, toAbs string) error {
332
+ data, err := os.ReadFile(fromAbs)
333
+ if err != nil {
334
+ return err
335
+ }
336
+ if err := os.MkdirAll(filepath.Dir(toAbs), 0o755); err != nil {
337
+ return err
338
+ }
339
+ updated := markCleanupPlanRenamed(v.nowDate(), action, string(data))
340
+ if err := os.WriteFile(toAbs, []byte(updated), 0o644); err != nil {
341
+ return err
342
+ }
343
+ return os.Remove(fromAbs)
344
+ }
345
+
346
+ func markCleanupPlanRenamed(date string, action CleanupPlanAction, content string) string {
347
+ canonical := strings.TrimSpace(action.CanonicalPath)
348
+ if canonical == "" {
349
+ canonical = "not specified"
350
+ }
351
+ notice := fmt.Sprintf("> Renamed by LLM-reviewed cleanup plan on %s. Reason: %s. Previous path: `%s`. Canonical/reference path: `%s`.\n\n", date, strings.TrimSpace(action.Reason), action.FromPath, canonical)
352
+ lines := strings.Split(content, "\n")
353
+ if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
354
+ end := -1
355
+ for i := 1; i < len(lines); i++ {
356
+ if strings.TrimSpace(lines[i]) == "---" {
357
+ end = i
358
+ break
359
+ }
360
+ }
361
+ if end > 0 {
362
+ header := upsertFrontmatterField(lines[1:end], "updated", date)
363
+ var b strings.Builder
364
+ b.WriteString("---\n")
365
+ b.WriteString(strings.Join(header, "\n"))
366
+ b.WriteString("\n---\n\n")
367
+ b.WriteString(notice)
368
+ b.WriteString(strings.TrimLeft(strings.Join(lines[end+1:], "\n"), "\n"))
369
+ if !strings.HasSuffix(b.String(), "\n") {
370
+ b.WriteString("\n")
371
+ }
372
+ return b.String()
373
+ }
374
+ }
375
+ return fmt.Sprintf("---\ntitle: %s\ntype: reference\nstatus: active\ncreated: %s\nupdated: %s\ntags: [cleanup, llm-reviewed]\ndomain: meta\n---\n\n%s%s\n", yamlQuote(titleizeSlug(strings.TrimSuffix(filepath.Base(action.ToPath), ".md"))), date, date, notice, strings.TrimSpace(content))
376
+ }
377
+
378
+ func (v *Vault) writeCleanupPlanApplyReport(result *CleanupPlanApplyResult, plan CleanupPlan) error {
379
+ reportRel, err := v.uniqueVaultRel(fmt.Sprintf("00-meta/reports/cleanup-plan-apply-%s.md", result.Date))
380
+ if err != nil {
381
+ return err
382
+ }
383
+ abs, err := v.SafeJoin(reportRel)
384
+ if err != nil {
385
+ return err
386
+ }
387
+ if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
388
+ return err
389
+ }
390
+ var b strings.Builder
391
+ b.WriteString("---\n")
392
+ fmt.Fprintf(&b, "title: %s\n", yamlQuote("LLM Cleanup Plan Apply "+result.Date))
393
+ b.WriteString("type: lint-report\n")
394
+ b.WriteString("status: active\n")
395
+ fmt.Fprintf(&b, "created: %s\n", result.Date)
396
+ fmt.Fprintf(&b, "updated: %s\n", result.Date)
397
+ b.WriteString("tags: [cleanup, llm-reviewed, ai-slop-cleaner]\n")
398
+ b.WriteString("domain: meta\n")
399
+ b.WriteString("---\n\n")
400
+ fmt.Fprintf(&b, "# LLM Cleanup Plan Apply %s\n\n", result.Date)
401
+ fmt.Fprintf(&b, "- Applied: `%t`\n", result.Applied)
402
+ if strings.TrimSpace(plan.Summary) != "" {
403
+ fmt.Fprintf(&b, "- Summary: %s\n", strings.TrimSpace(plan.Summary))
404
+ }
405
+ b.WriteString("\n## Actions\n\n")
406
+ for i, action := range result.Actions {
407
+ fmt.Fprintf(&b, "### %d. `%s`\n\n", i+1, action.Type)
408
+ if action.Scope != "" {
409
+ fmt.Fprintf(&b, "- Scope: `%s`\n", action.Scope)
410
+ }
411
+ if action.FromPath != "" || action.ToPath != "" {
412
+ fmt.Fprintf(&b, "- Rename: `%s` → `%s`\n", action.FromPath, action.ToPath)
413
+ }
414
+ fmt.Fprintf(&b, "- Planned changes: `%d`\n", action.PlannedChanges)
415
+ if strings.TrimSpace(action.Reason) != "" {
416
+ fmt.Fprintf(&b, "- Reason: %s\n", strings.TrimSpace(action.Reason))
417
+ }
418
+ if len(action.ReportPaths) > 0 {
419
+ fmt.Fprintf(&b, "- Reports: `%s`\n", strings.Join(action.ReportPaths, "`, `"))
420
+ }
421
+ b.WriteString("\n")
422
+ }
423
+ if len(result.Warnings) > 0 {
424
+ b.WriteString("## Warnings\n\n")
425
+ for _, warning := range result.Warnings {
426
+ fmt.Fprintf(&b, "- %s\n", warning)
427
+ }
428
+ b.WriteString("\n")
429
+ }
430
+ b.WriteString("## Changelog\n\n")
431
+ fmt.Fprintf(&b, "- %s: Generated by `llm-wiki cleanup apply-plan --apply`.\n", result.Date)
432
+ if err := os.WriteFile(abs, []byte(b.String()), 0o644); err != nil {
433
+ return err
434
+ }
435
+ result.ReportPaths = append(result.ReportPaths, reportRel)
436
+ sort.Strings(result.ReportPaths)
437
+ return nil
438
+ }
439
+
440
+ func repositoryReferenceCleanupCandidates(plan repositoryReferenceCleanupPlan) []CleanupCandidate {
441
+ candidates := make([]CleanupCandidate, 0, len(plan.result.Bundles))
442
+ for _, bundle := range plan.result.Bundles {
443
+ files := []CleanupCandidateFile{{
444
+ Path: bundle.BundlePath,
445
+ Role: "would_create_bundle",
446
+ }}
447
+ for i, fragmentPath := range bundle.FragmentPaths {
448
+ file := CleanupCandidateFile{Path: fragmentPath, Role: "fragment"}
449
+ if i < len(bundle.ArchivedPaths) {
450
+ file.TargetPath = bundle.ArchivedPaths[i]
451
+ }
452
+ files = append(files, file)
453
+ }
454
+ candidates = append(candidates, CleanupCandidate{
455
+ ID: "repository-references:" + bundle.Repository,
456
+ Kind: "repository-reference-bundle",
457
+ Title: titleizeSlug(bundle.Repository) + " repository reference fragments",
458
+ Reason: fmt.Sprintf("%d dated repository-reference fragments can be consolidated into one canonical bundle.", len(bundle.FragmentPaths)),
459
+ Confidence: "high",
460
+ Files: files,
461
+ RecommendedAction: CleanupPlanAction{
462
+ Type: "run_scope",
463
+ Scope: "repository-references",
464
+ ArchiveRoot: plan.result.ArchiveRoot,
465
+ Reason: "Consolidate dated repository-reference fragments through the guarded deterministic cleanup scope.",
466
+ },
467
+ })
468
+ }
469
+ return candidates
470
+ }
471
+
472
+ func duplicateSlugCleanupCandidates(plan duplicateSlugCleanupPlan) []CleanupCandidate {
473
+ bySlug := map[string][]DuplicateSlugRename{}
474
+ for _, rename := range plan.result.Renames {
475
+ bySlug[rename.Slug] = append(bySlug[rename.Slug], rename)
476
+ }
477
+ slugs := make([]string, 0, len(bySlug))
478
+ for slug := range bySlug {
479
+ slugs = append(slugs, slug)
480
+ }
481
+ sort.Strings(slugs)
482
+ candidates := make([]CleanupCandidate, 0, len(slugs))
483
+ for _, slug := range slugs {
484
+ renames := bySlug[slug]
485
+ sort.Slice(renames, func(i, j int) bool { return renames[i].FromPath < renames[j].FromPath })
486
+ files := []CleanupCandidateFile{{Path: renames[0].CanonicalPath, Role: "canonical"}}
487
+ for _, rename := range renames {
488
+ files = append(files, CleanupCandidateFile{Path: rename.FromPath, Role: "duplicate", TargetPath: rename.ToPath})
489
+ }
490
+ candidates = append(candidates, CleanupCandidate{
491
+ ID: "duplicate-slugs:" + slug,
492
+ Kind: "duplicate-slug",
493
+ Title: "Duplicate slug: " + slug,
494
+ Reason: fmt.Sprintf("%d non-canonical pages share slug %q and can be renamed to remove Obsidian ambiguity warnings.", len(renames), slug),
495
+ Confidence: "high",
496
+ Files: files,
497
+ RecommendedAction: CleanupPlanAction{
498
+ Type: "run_scope",
499
+ Scope: "duplicate-slugs",
500
+ Reason: "Resolve duplicate slug warnings through the guarded deterministic cleanup scope.",
501
+ },
502
+ })
503
+ }
504
+ return candidates
505
+ }
506
+
507
+ func cleanupPlanSchema(scope, archiveRoot string) CleanupPlan {
508
+ action := CleanupPlanAction{Type: "run_scope", Scope: cleanupScopeOrDefault(scope), Reason: "LLM-reviewed reason for applying this guarded cleanup action."}
509
+ if strings.TrimSpace(archiveRoot) != "" {
510
+ action.ArchiveRoot = strings.Trim(strings.TrimSpace(filepath.ToSlash(archiveRoot)), "/")
511
+ }
512
+ return CleanupPlan{
513
+ Version: cleanupPlanVersion,
514
+ Scope: cleanupScopeOrDefault(scope),
515
+ Summary: "Explain why these candidates are true cleanup targets and why this strategy is safest.",
516
+ Actions: []CleanupPlanAction{action},
517
+ }
518
+ }
519
+
520
+ func cleanupLLMInstructions() []string {
521
+ return []string{
522
+ "Review candidates as suggestions, not commands; include only true cleanup targets in the plan.",
523
+ "Prefer run_scope for existing guarded cleanup behavior; use rename_page only for a narrowly justified targeted rename.",
524
+ "Do not edit 10-sources bodies, .obsidian, absolute paths, or paths containing traversal.",
525
+ "Do not invent files; every rename_page from_path must exist and every to_path must be a new vault-relative Markdown path.",
526
+ "Keep destructive execution separate: first validate without --apply, then apply only after the dry-run output matches the intended strategy.",
527
+ }
528
+ }
529
+
530
+ func normalizeCleanupPlan(plan CleanupPlan) CleanupPlan {
531
+ plan.Scope = cleanupScopeOrDefault(plan.Scope)
532
+ for i, action := range plan.Actions {
533
+ plan.Actions[i] = normalizeCleanupPlanAction(action)
534
+ }
535
+ return plan
536
+ }
537
+
538
+ func normalizeCleanupPlanAction(action CleanupPlanAction) CleanupPlanAction {
539
+ action.Type = strings.TrimSpace(action.Type)
540
+ action.Scope = cleanupScopeOrDefault(action.Scope)
541
+ action.ArchiveRoot = strings.Trim(strings.TrimSpace(filepath.ToSlash(action.ArchiveRoot)), "/")
542
+ action.FromPath = cleanCleanupPlanPath(action.FromPath)
543
+ action.ToPath = cleanCleanupPlanPath(action.ToPath)
544
+ action.CanonicalPath = cleanCleanupPlanPath(action.CanonicalPath)
545
+ action.Slug = strings.TrimSpace(action.Slug)
546
+ action.Reason = strings.TrimSpace(action.Reason)
547
+ return action
548
+ }
549
+
550
+ func cleanupScopeOrDefault(scope string) string {
551
+ scope = strings.TrimSpace(scope)
552
+ if scope == "" {
553
+ return "repository-references"
554
+ }
555
+ return scope
556
+ }
557
+
558
+ func cleanCleanupPlanPath(path string) string {
559
+ path = strings.TrimSpace(filepath.ToSlash(path))
560
+ if path == "" {
561
+ return ""
562
+ }
563
+ return path
564
+ }
565
+
566
+ func isCleanupPlanProtectedPath(path string) bool {
567
+ path = cleanCleanupPlanPath(path)
568
+ return path == ".obsidian" || strings.HasPrefix(path, ".obsidian/") || path == "10-sources" || strings.HasPrefix(path, "10-sources/")
569
+ }