@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.
- package/README.md +200 -47
- package/cmd/llm-wiki/hook_test.go +108 -0
- package/cmd/llm-wiki/main.go +457 -11
- package/go.mod +1 -1
- package/internal/mcpautostart/autostart.go +285 -0
- package/internal/mcpautostart/autostart_test.go +169 -0
- package/internal/mcpautostart/detach_unix.go +9 -0
- package/internal/mcpautostart/detach_windows.go +7 -0
- package/internal/mcpautostart/proxy.go +162 -0
- package/internal/sessionctx/sessionctx.go +649 -0
- package/internal/sessionctx/sessionctx_test.go +415 -0
- package/internal/wiki/cleanup.go +1189 -0
- package/internal/wiki/cleanup_plan.go +569 -0
- package/internal/wiki/cleanup_test.go +450 -0
- package/internal/wiki/init.go +77 -0
- package/internal/wiki/vault_test.go +41 -0
- package/npm/lib/runner.js +50 -10
- package/package.json +1 -1
|
@@ -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
|
+
}
|