pi-para 0.6.0 → 0.6.1

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 (208) hide show
  1. package/ARCHITECTURE.md +0 -9
  2. package/CHANGELOG.md +13 -0
  3. package/CONFIGURATION.md +0 -23
  4. package/README.md +1 -29
  5. package/SETUP.md +1 -1
  6. package/STRUCTURE.md +0 -9
  7. package/dist/atomic-write.d.ts +2 -0
  8. package/dist/atomic-write.d.ts.map +1 -0
  9. package/dist/atomic-write.js +18 -0
  10. package/dist/atomic-write.js.map +1 -0
  11. package/dist/capture.d.ts +34 -0
  12. package/dist/capture.d.ts.map +1 -0
  13. package/dist/capture.js +461 -0
  14. package/dist/capture.js.map +1 -0
  15. package/dist/cli-legacy.d.ts +4 -0
  16. package/dist/cli-legacy.d.ts.map +1 -0
  17. package/dist/cli-legacy.js +41 -0
  18. package/dist/cli-legacy.js.map +1 -0
  19. package/dist/cli.d.ts +8 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +427 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/commands.d.ts +15 -0
  24. package/dist/commands.d.ts.map +1 -0
  25. package/dist/commands.js +719 -0
  26. package/dist/commands.js.map +1 -0
  27. package/dist/config.d.ts +94 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +228 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/context.d.ts +63 -0
  32. package/dist/context.d.ts.map +1 -0
  33. package/dist/context.js +311 -0
  34. package/dist/context.js.map +1 -0
  35. package/dist/credentials.d.ts +27 -0
  36. package/dist/credentials.d.ts.map +1 -0
  37. package/dist/credentials.js +85 -0
  38. package/dist/credentials.js.map +1 -0
  39. package/dist/daemon.d.ts +49 -0
  40. package/dist/daemon.d.ts.map +1 -0
  41. package/dist/daemon.js +226 -0
  42. package/dist/daemon.js.map +1 -0
  43. package/dist/doctor.d.ts +20 -0
  44. package/dist/doctor.d.ts.map +1 -0
  45. package/dist/doctor.js +176 -0
  46. package/dist/doctor.js.map +1 -0
  47. package/dist/frontmatter.d.ts +59 -0
  48. package/dist/frontmatter.d.ts.map +1 -0
  49. package/dist/frontmatter.js +170 -0
  50. package/dist/frontmatter.js.map +1 -0
  51. package/dist/index.d.ts +21 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +342 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/ingest.d.ts +88 -0
  56. package/dist/ingest.d.ts.map +1 -0
  57. package/dist/ingest.js +105 -0
  58. package/dist/ingest.js.map +1 -0
  59. package/dist/link-discovery.d.ts +40 -0
  60. package/dist/link-discovery.d.ts.map +1 -0
  61. package/dist/link-discovery.js +122 -0
  62. package/dist/link-discovery.js.map +1 -0
  63. package/dist/link-utils.d.ts +30 -0
  64. package/dist/link-utils.d.ts.map +1 -0
  65. package/dist/link-utils.js +145 -0
  66. package/dist/link-utils.js.map +1 -0
  67. package/dist/lint.d.ts +39 -0
  68. package/dist/lint.d.ts.map +1 -0
  69. package/dist/lint.js +827 -0
  70. package/dist/lint.js.map +1 -0
  71. package/dist/maintainer.d.ts +24 -0
  72. package/dist/maintainer.d.ts.map +1 -0
  73. package/dist/maintainer.js +358 -0
  74. package/dist/maintainer.js.map +1 -0
  75. package/dist/model-resolver.d.ts +30 -0
  76. package/dist/model-resolver.d.ts.map +1 -0
  77. package/dist/model-resolver.js +71 -0
  78. package/dist/model-resolver.js.map +1 -0
  79. package/dist/paths.d.ts +20 -0
  80. package/dist/paths.d.ts.map +1 -0
  81. package/dist/paths.js +33 -0
  82. package/dist/paths.js.map +1 -0
  83. package/dist/processor.d.ts +14 -0
  84. package/dist/processor.d.ts.map +1 -0
  85. package/dist/processor.js +361 -0
  86. package/dist/processor.js.map +1 -0
  87. package/dist/qmd-providers.d.ts +12 -0
  88. package/dist/qmd-providers.d.ts.map +1 -0
  89. package/dist/qmd-providers.js +45 -0
  90. package/dist/qmd-providers.js.map +1 -0
  91. package/dist/query.d.ts +69 -0
  92. package/dist/query.d.ts.map +1 -0
  93. package/dist/query.js +120 -0
  94. package/dist/query.js.map +1 -0
  95. package/dist/raw.d.ts +57 -0
  96. package/dist/raw.d.ts.map +1 -0
  97. package/dist/raw.js +219 -0
  98. package/dist/raw.js.map +1 -0
  99. package/dist/redact.d.ts +21 -0
  100. package/dist/redact.d.ts.map +1 -0
  101. package/dist/redact.js +143 -0
  102. package/dist/redact.js.map +1 -0
  103. package/dist/repair.d.ts +11 -0
  104. package/dist/repair.d.ts.map +1 -0
  105. package/dist/repair.js +41 -0
  106. package/dist/repair.js.map +1 -0
  107. package/dist/scheduler/controls.d.ts +35 -0
  108. package/dist/scheduler/controls.d.ts.map +1 -0
  109. package/dist/scheduler/controls.js +98 -0
  110. package/dist/scheduler/controls.js.map +1 -0
  111. package/dist/scheduler/index.d.ts +48 -0
  112. package/dist/scheduler/index.d.ts.map +1 -0
  113. package/dist/scheduler/index.js +193 -0
  114. package/dist/scheduler/index.js.map +1 -0
  115. package/dist/scheduler/leases.d.ts +22 -0
  116. package/dist/scheduler/leases.d.ts.map +1 -0
  117. package/dist/scheduler/leases.js +74 -0
  118. package/dist/scheduler/leases.js.map +1 -0
  119. package/dist/scheduler/session-capture.d.ts +33 -0
  120. package/dist/scheduler/session-capture.d.ts.map +1 -0
  121. package/dist/scheduler/session-capture.js +108 -0
  122. package/dist/scheduler/session-capture.js.map +1 -0
  123. package/dist/scheduler/state.d.ts +56 -0
  124. package/dist/scheduler/state.d.ts.map +1 -0
  125. package/dist/scheduler/state.js +210 -0
  126. package/dist/scheduler/state.js.map +1 -0
  127. package/dist/scope.d.ts +54 -0
  128. package/dist/scope.d.ts.map +1 -0
  129. package/dist/scope.js +161 -0
  130. package/dist/scope.js.map +1 -0
  131. package/dist/scripts/bulk-link-fix.d.ts +9 -0
  132. package/dist/scripts/bulk-link-fix.d.ts.map +1 -0
  133. package/dist/scripts/bulk-link-fix.js +106 -0
  134. package/dist/scripts/bulk-link-fix.js.map +1 -0
  135. package/dist/session-tools.d.ts +33 -0
  136. package/dist/session-tools.d.ts.map +1 -0
  137. package/dist/session-tools.js +211 -0
  138. package/dist/session-tools.js.map +1 -0
  139. package/dist/settings.d.ts +19 -0
  140. package/dist/settings.d.ts.map +1 -0
  141. package/dist/settings.js +106 -0
  142. package/dist/settings.js.map +1 -0
  143. package/dist/setup.d.ts +29 -0
  144. package/dist/setup.d.ts.map +1 -0
  145. package/dist/setup.js +90 -0
  146. package/dist/setup.js.map +1 -0
  147. package/dist/state.d.ts +54 -0
  148. package/dist/state.d.ts.map +1 -0
  149. package/dist/state.js +176 -0
  150. package/dist/state.js.map +1 -0
  151. package/dist/status.d.ts +22 -0
  152. package/dist/status.d.ts.map +1 -0
  153. package/dist/status.js +93 -0
  154. package/dist/status.js.map +1 -0
  155. package/dist/store.d.ts +71 -0
  156. package/dist/store.d.ts.map +1 -0
  157. package/dist/store.js +282 -0
  158. package/dist/store.js.map +1 -0
  159. package/dist/summarize.d.ts +60 -0
  160. package/dist/summarize.d.ts.map +1 -0
  161. package/dist/summarize.js +266 -0
  162. package/dist/summarize.js.map +1 -0
  163. package/dist/tag-registry.d.ts +56 -0
  164. package/dist/tag-registry.d.ts.map +1 -0
  165. package/dist/tag-registry.js +146 -0
  166. package/dist/tag-registry.js.map +1 -0
  167. package/dist/templates/prompts.d.ts +51 -0
  168. package/dist/templates/prompts.d.ts.map +1 -0
  169. package/dist/templates/prompts.js +203 -0
  170. package/dist/templates/prompts.js.map +1 -0
  171. package/dist/tools.d.ts +26 -0
  172. package/dist/tools.d.ts.map +1 -0
  173. package/dist/tools.js +1198 -0
  174. package/dist/tools.js.map +1 -0
  175. package/dist/watcher.d.ts +25 -0
  176. package/dist/watcher.d.ts.map +1 -0
  177. package/dist/watcher.js +84 -0
  178. package/dist/watcher.js.map +1 -0
  179. package/dist/webui/client/dist/index.html +1885 -0
  180. package/dist/webui/server.d.ts +31 -0
  181. package/dist/webui/server.d.ts.map +1 -0
  182. package/dist/webui/server.js +566 -0
  183. package/dist/webui/server.js.map +1 -0
  184. package/dist/wiki-tool-guidance.d.ts +6 -0
  185. package/dist/wiki-tool-guidance.d.ts.map +1 -0
  186. package/dist/wiki-tool-guidance.js +70 -0
  187. package/dist/wiki-tool-guidance.js.map +1 -0
  188. package/dist/wiki.d.ts +108 -0
  189. package/dist/wiki.d.ts.map +1 -0
  190. package/dist/wiki.js +497 -0
  191. package/dist/wiki.js.map +1 -0
  192. package/package.json +2 -1
  193. package/scripts/build-assets.mjs +1 -13
  194. package/skills/setup/SKILL.md +1 -1
  195. package/src/cli.ts +0 -70
  196. package/src/config.ts +0 -25
  197. package/src/index.ts +1 -38
  198. package/src/templates/prompts.ts +0 -56
  199. package/scripts/gepa/dataset.py +0 -192
  200. package/scripts/gepa/lm_providers.py +0 -472
  201. package/scripts/gepa/metric.py +0 -279
  202. package/scripts/gepa/optimize.py +0 -367
  203. package/scripts/gepa/program.py +0 -99
  204. package/scripts/gepa/pyproject.toml +0 -19
  205. package/scripts/gepa/run-all.sh +0 -53
  206. package/scripts/gepa/wiki_reader.py +0 -113
  207. package/src/gepa/index.ts +0 -287
  208. package/src/gepa/optimized/capture-prompt.txt +0 -65
package/dist/lint.js ADDED
@@ -0,0 +1,827 @@
1
+ /**
2
+ * Wiki health checks — validates wiki structure and content integrity.
3
+ *
4
+ * Checks for orphan pages, broken links, stale pages, scope drift,
5
+ * archive candidates, missing pages, frontmatter issues, index drift,
6
+ * empty categories, and duplicate slugs.
7
+ *
8
+ * When autoFix is true (default), fixable issues are repaired in-place:
9
+ * broken links removed, index rebuilt for drift, missing frontmatter fields set.
10
+ */
11
+ import { readFile, writeFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { listPages, readPage, writePage, readIndex, writeIndex, PARA_CATEGORIES, } from "./wiki.js";
14
+ import { extractWikilinks, removeWikilink, autoLinkSlugs, syncFrontmatterLinks, } from "./link-utils.js";
15
+ import { normalizeTags, normalizeScopes } from "./tag-registry.js";
16
+ import { containsSecrets, redactSecrets } from "./redact.js";
17
+ import { CURRENT_SCHEMA_VERSION, migrateToLatest, parseFrontmatter, serializeFrontmatter, } from "./frontmatter.js";
18
+ // -- Helpers ----------------------------------------------------------------
19
+ function daysBetween(a, b) {
20
+ return Math.abs(b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24);
21
+ }
22
+ function makeStats(pages, totalLinks, brokenLinks, orphanCount, logContent) {
23
+ const byCategory = {
24
+ projects: 0,
25
+ areas: 0,
26
+ resources: 0,
27
+ archives: 0,
28
+ };
29
+ let oldest = "";
30
+ let newest = "";
31
+ for (const p of pages) {
32
+ byCategory[p.category]++;
33
+ const created = p.frontmatter.created;
34
+ if (!oldest || created < oldest)
35
+ oldest = created;
36
+ if (!newest || created > newest)
37
+ newest = created;
38
+ }
39
+ // Find last ingest date from log
40
+ let lastIngest = "";
41
+ const ingestRe = /## \[(\d{4}-\d{2}-\d{2})\] ingest/g;
42
+ let match;
43
+ while ((match = ingestRe.exec(logContent)) !== null) {
44
+ if (!lastIngest || match[1] > lastIngest)
45
+ lastIngest = match[1];
46
+ }
47
+ return {
48
+ totalPages: pages.length,
49
+ byCategory,
50
+ totalLinks,
51
+ brokenLinks,
52
+ orphanPages: orphanCount,
53
+ oldestPage: oldest,
54
+ newestPage: newest,
55
+ lastIngest,
56
+ };
57
+ }
58
+ // -- Checks -----------------------------------------------------------------
59
+ /** 1. Orphan pages: no inbound [[wikilinks]] from any other page */
60
+ function checkOrphans(pages, allSlugs) {
61
+ // Build inbound link map
62
+ const inbound = new Set();
63
+ for (const p of pages) {
64
+ const bodyLinks = extractWikilinks(p.body);
65
+ const fmLinks = p.frontmatter.links;
66
+ for (const link of [...bodyLinks, ...fmLinks]) {
67
+ inbound.add(link);
68
+ }
69
+ }
70
+ const issues = [];
71
+ for (const p of pages) {
72
+ if (!inbound.has(p.slug)) {
73
+ issues.push({
74
+ severity: "info",
75
+ category: "orphan",
76
+ page: `${p.category}/${p.slug}`,
77
+ message: `Orphan page — no inbound [[wikilinks]] to "${p.frontmatter.title}"`,
78
+ autoFixable: false,
79
+ });
80
+ }
81
+ }
82
+ return issues;
83
+ }
84
+ /** 2. Broken links: [[wikilinks]] pointing to non-existent pages */
85
+ function checkBrokenLinks(pages, allSlugs) {
86
+ const issues = [];
87
+ for (const p of pages) {
88
+ const bodyLinks = extractWikilinks(p.body);
89
+ const fmLinks = p.frontmatter.links;
90
+ const allLinks = new Set([...bodyLinks, ...fmLinks]);
91
+ for (const link of allLinks) {
92
+ if (!allSlugs.has(link)) {
93
+ issues.push({
94
+ severity: "error",
95
+ category: "broken-link",
96
+ page: `${p.category}/${p.slug}`,
97
+ message: `Broken link [[${link}]] — target page does not exist`,
98
+ autoFixable: true,
99
+ });
100
+ }
101
+ }
102
+ }
103
+ return issues;
104
+ }
105
+ /** 3. Stale pages: not updated in > staleDays */
106
+ function checkStale(pages, staleDays, now) {
107
+ const issues = [];
108
+ for (const p of pages) {
109
+ if (p.category === "archives")
110
+ continue; // archives are expected to be stale
111
+ const updated = new Date(p.frontmatter.updated);
112
+ if (daysBetween(updated, now) > staleDays) {
113
+ issues.push({
114
+ severity: "warning",
115
+ category: "stale",
116
+ page: `${p.category}/${p.slug}`,
117
+ message: `Stale page — not updated in >${staleDays} days (last: ${p.frontmatter.updated})`,
118
+ autoFixable: false,
119
+ });
120
+ }
121
+ }
122
+ return issues;
123
+ }
124
+ /** 4. Scope drift: projects/ pages where scope doesn't include the slug */
125
+ function checkScopeDrift(pages) {
126
+ const issues = [];
127
+ for (const p of pages) {
128
+ if (p.category !== "projects")
129
+ continue;
130
+ if (!p.frontmatter.scope.includes(p.slug)) {
131
+ issues.push({
132
+ severity: "warning",
133
+ category: "scope-drift",
134
+ page: `${p.category}/${p.slug}`,
135
+ message: `Scope drift — projects/ page scope [${p.frontmatter.scope.join(", ")}] does not include its own slug "${p.slug}"`,
136
+ autoFixable: true,
137
+ });
138
+ }
139
+ }
140
+ return issues;
141
+ }
142
+ /** 5. Archive candidates: project pages with no recent log entries */
143
+ function checkArchiveCandidates(pages, logContent, staleDays, now) {
144
+ const issues = [];
145
+ for (const p of pages) {
146
+ if (p.category !== "projects")
147
+ continue;
148
+ // Check if the page or its slug appears in any recent log entry
149
+ const logEntryRe = /## \[(\d{4}-\d{2}-\d{2})\] \w+ \|[^\n]*/g;
150
+ let hasRecentEntry = false;
151
+ let m;
152
+ while ((m = logEntryRe.exec(logContent)) !== null) {
153
+ const entryDate = new Date(m[0].match(/\d{4}-\d{2}-\d{2}/)[0]);
154
+ const entryText = m[0];
155
+ if (daysBetween(entryDate, now) <= staleDays &&
156
+ (entryText.includes(p.slug) || entryText.includes(p.frontmatter.title))) {
157
+ hasRecentEntry = true;
158
+ break;
159
+ }
160
+ }
161
+ // Also check lines below each entry header for page references
162
+ if (!hasRecentEntry) {
163
+ const updated = new Date(p.frontmatter.updated);
164
+ if (daysBetween(updated, now) > staleDays) {
165
+ issues.push({
166
+ severity: "info",
167
+ category: "archive-candidate",
168
+ page: `${p.category}/${p.slug}`,
169
+ message: `Archive candidate — project has no recent log entries and hasn't been updated in >${staleDays} days`,
170
+ autoFixable: false,
171
+ });
172
+ }
173
+ }
174
+ }
175
+ return issues;
176
+ }
177
+ /** 6. Missing pages: slugs referenced in 2+ pages but lacking own page */
178
+ function checkMissing(pages, allSlugs) {
179
+ // Count how many distinct pages reference each non-existent slug
180
+ const refCounts = new Map();
181
+ for (const p of pages) {
182
+ const bodyLinks = extractWikilinks(p.body);
183
+ const fmLinks = p.frontmatter.links;
184
+ const allLinks = new Set([...bodyLinks, ...fmLinks]);
185
+ for (const link of allLinks) {
186
+ if (!allSlugs.has(link)) {
187
+ if (!refCounts.has(link))
188
+ refCounts.set(link, new Set());
189
+ refCounts.get(link).add(p.slug);
190
+ }
191
+ }
192
+ }
193
+ const issues = [];
194
+ for (const [slug, refs] of refCounts) {
195
+ if (refs.size >= 2) {
196
+ issues.push({
197
+ severity: "info",
198
+ category: "missing-page",
199
+ message: `Missing page "[[${slug}]]" — referenced by ${refs.size} pages: ${[...refs].join(", ")}`,
200
+ autoFixable: false,
201
+ });
202
+ }
203
+ }
204
+ return issues;
205
+ }
206
+ /** 7. Empty categories: PARA categories with zero pages */
207
+ function checkEmptyCategories(pages) {
208
+ const counts = {
209
+ projects: 0,
210
+ areas: 0,
211
+ resources: 0,
212
+ archives: 0,
213
+ };
214
+ for (const p of pages) {
215
+ counts[p.category]++;
216
+ }
217
+ const issues = [];
218
+ for (const cat of PARA_CATEGORIES) {
219
+ if (cat === "archives")
220
+ continue; // archives being empty is fine
221
+ if (counts[cat] === 0) {
222
+ issues.push({
223
+ severity: "info",
224
+ category: "empty-category",
225
+ message: `Empty category "${cat}" — no pages`,
226
+ autoFixable: false,
227
+ });
228
+ }
229
+ }
230
+ return issues;
231
+ }
232
+ /** 8. Frontmatter issues: missing required fields, empty scope */
233
+ function checkFrontmatter(pages) {
234
+ const issues = [];
235
+ for (const p of pages) {
236
+ const fm = p.frontmatter;
237
+ if (!fm.title || fm.title === "Untitled") {
238
+ issues.push({
239
+ severity: "error",
240
+ category: "frontmatter",
241
+ page: `${p.category}/${p.slug}`,
242
+ message: `Missing or default title`,
243
+ autoFixable: true,
244
+ });
245
+ }
246
+ if (fm.scope.length === 0) {
247
+ issues.push({
248
+ severity: "warning",
249
+ category: "frontmatter",
250
+ page: `${p.category}/${p.slug}`,
251
+ message: `Empty scope — page has no scope tags`,
252
+ autoFixable: true,
253
+ });
254
+ }
255
+ if (!fm.created) {
256
+ issues.push({
257
+ severity: "error",
258
+ category: "frontmatter",
259
+ page: `${p.category}/${p.slug}`,
260
+ message: `Missing created date`,
261
+ autoFixable: true,
262
+ });
263
+ }
264
+ if (!fm.updated) {
265
+ issues.push({
266
+ severity: "error",
267
+ category: "frontmatter",
268
+ page: `${p.category}/${p.slug}`,
269
+ message: `Missing updated date`,
270
+ autoFixable: true,
271
+ });
272
+ }
273
+ }
274
+ return issues;
275
+ }
276
+ /** 9. Index drift: pages on disk but not referenced in index.md */
277
+ function checkIndexDrift(pages, indexContent) {
278
+ const issues = [];
279
+ for (const p of pages) {
280
+ // Check if slug appears as [[slug]] in the index
281
+ if (!indexContent.includes(`[[${p.slug}]]`)) {
282
+ issues.push({
283
+ severity: "warning",
284
+ category: "index-drift",
285
+ page: `${p.category}/${p.slug}`,
286
+ message: `Page not listed in index.md`,
287
+ autoFixable: true,
288
+ });
289
+ }
290
+ }
291
+ return issues;
292
+ }
293
+ /** 10. Duplicate slugs: same slug in different categories */
294
+ function checkDuplicateSlugs(pages) {
295
+ const slugMap = new Map();
296
+ for (const p of pages) {
297
+ const existing = slugMap.get(p.slug) ?? [];
298
+ existing.push(p.category);
299
+ slugMap.set(p.slug, existing);
300
+ }
301
+ const issues = [];
302
+ for (const [slug, categories] of slugMap) {
303
+ if (categories.length > 1) {
304
+ issues.push({
305
+ severity: "warning",
306
+ category: "duplicate-slug",
307
+ message: `Duplicate slug "${slug}" exists in: ${categories.join(", ")}`,
308
+ autoFixable: false,
309
+ });
310
+ }
311
+ }
312
+ return issues;
313
+ }
314
+ /** 11. Link sync: slug mentions in body not wrapped in [[wikilinks]] */
315
+ function checkLinkSync(pages, allSlugs) {
316
+ const issues = [];
317
+ for (const p of pages) {
318
+ // Find slugs mentioned in body but not wrapped in [[...]]
319
+ const bodyLinks = new Set(extractWikilinks(p.body));
320
+ const sortedSlugs = [...allSlugs]
321
+ .filter(s => s !== p.slug)
322
+ .sort((a, b) => b.length - a.length);
323
+ const unlinked = [];
324
+ for (const slug of sortedSlugs) {
325
+ if (bodyLinks.has(slug))
326
+ continue;
327
+ // Check if slug appears as plain text in body (word-boundary match)
328
+ const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
329
+ const re = new RegExp(`(?<![a-z0-9-])${escaped}(?![a-z0-9-])`);
330
+ if (re.test(p.body)) {
331
+ unlinked.push(slug);
332
+ }
333
+ }
334
+ if (unlinked.length > 0) {
335
+ issues.push({
336
+ severity: "warning",
337
+ category: "link-sync",
338
+ page: `${p.category}/${p.slug}`,
339
+ message: `${unlinked.length} unlinked slug mention(s): ${unlinked.join(", ")}`,
340
+ autoFixable: true,
341
+ });
342
+ }
343
+ // Check frontmatter.links out of sync with body
344
+ const expectedLinks = syncFrontmatterLinks(p.body);
345
+ const fmSet = new Set(p.frontmatter.links);
346
+ const expectedSet = new Set(expectedLinks);
347
+ const missingInFm = expectedLinks.filter(l => !fmSet.has(l));
348
+ const extraInFm = p.frontmatter.links.filter(l => !expectedSet.has(l));
349
+ if (missingInFm.length > 0 || extraInFm.length > 0) {
350
+ issues.push({
351
+ severity: "warning",
352
+ category: "link-sync",
353
+ page: `${p.category}/${p.slug}`,
354
+ message: `frontmatter.links out of sync with body [[wikilinks]]`,
355
+ autoFixable: true,
356
+ });
357
+ }
358
+ }
359
+ return issues;
360
+ }
361
+ /** 12. Tag health: spaces, scope duplicates, non-canonical tags */
362
+ function checkTagHealth(pages) {
363
+ const issues = [];
364
+ for (const p of pages) {
365
+ const tags = p.frontmatter.tags;
366
+ const scope = p.frontmatter.scope;
367
+ const scopeSet = new Set(scope.map(s => s.toLowerCase()));
368
+ // Check for tags with spaces
369
+ const spaceTags = tags.filter(t => t.includes(" "));
370
+ if (spaceTags.length > 0) {
371
+ issues.push({
372
+ severity: "warning",
373
+ category: "tag-health",
374
+ page: `${p.category}/${p.slug}`,
375
+ message: `Tags with spaces: ${spaceTags.map(t => `"${t}"`).join(", ")}`,
376
+ autoFixable: true,
377
+ });
378
+ }
379
+ // Check for tags duplicating scope
380
+ const scopeDupes = tags.filter(t => scopeSet.has(t.toLowerCase()));
381
+ if (scopeDupes.length > 0) {
382
+ issues.push({
383
+ severity: "info",
384
+ category: "tag-health",
385
+ page: `${p.category}/${p.slug}`,
386
+ message: `Tags duplicating scope: ${scopeDupes.join(", ")}`,
387
+ autoFixable: true,
388
+ });
389
+ }
390
+ // Check if normalization would change the tag list
391
+ const normalized = normalizeTags(tags, scope);
392
+ const currentSorted = [...tags].sort();
393
+ if (JSON.stringify(normalized) !== JSON.stringify(currentSorted)) {
394
+ // Only report if not already covered by the above checks
395
+ const aliasChanges = tags.filter(t => {
396
+ const n = t.trim().toLowerCase().replace(/\s+/g, "-");
397
+ return normalized.includes(n) === false && !scopeSet.has(n) && !t.includes(" ");
398
+ });
399
+ if (aliasChanges.length > 0) {
400
+ issues.push({
401
+ severity: "info",
402
+ category: "tag-health",
403
+ page: `${p.category}/${p.slug}`,
404
+ message: `Tags to canonicalize: ${aliasChanges.join(", ")}`,
405
+ autoFixable: true,
406
+ });
407
+ }
408
+ }
409
+ }
410
+ return issues;
411
+ }
412
+ /** 13. Secrets: detect API keys, tokens, passwords in page content */
413
+ function checkSecrets(pages) {
414
+ const issues = [];
415
+ for (const p of pages) {
416
+ if (containsSecrets(p.body)) {
417
+ issues.push({
418
+ severity: "error",
419
+ category: "secrets",
420
+ page: `${p.category}/${p.slug}`,
421
+ message: `Page contains potential secrets (API keys, tokens, passwords)`,
422
+ autoFixable: true,
423
+ });
424
+ }
425
+ }
426
+ return issues;
427
+ }
428
+ /** 15. Schema version: pages below CURRENT_SCHEMA_VERSION need migration */
429
+ function checkSchemaVersion(pages) {
430
+ const issues = [];
431
+ for (const p of pages) {
432
+ const version = p.frontmatter.schemaVersion ?? 1;
433
+ if (version < CURRENT_SCHEMA_VERSION) {
434
+ issues.push({
435
+ severity: "warning",
436
+ category: "schema-version",
437
+ page: `${p.category}/${p.slug}`,
438
+ message: `Schema version ${version} is below current (${CURRENT_SCHEMA_VERSION}) — needs migration`,
439
+ autoFixable: true,
440
+ });
441
+ }
442
+ }
443
+ return issues;
444
+ }
445
+ /** 14. PARA category misuse: reference docs in projects/ instead of resources/ */
446
+ function checkCategoryMisuse(pages) {
447
+ const issues = [];
448
+ // projects/ should have goal-defined work with end dates, not reference docs.
449
+ // Heuristic: if a projects/ page's slug contains a scope project name as prefix
450
+ // (e.g. "pi-para-daemon" in scope "pi-para"), it's likely a sub-topic reference
451
+ // doc, not a standalone project.
452
+ for (const p of pages) {
453
+ if (p.category !== "projects")
454
+ continue;
455
+ // Check if the slug is a sub-topic of a scope value
456
+ // e.g. slug "pi-para-daemon" with scope ["pi-para"] → sub-topic
457
+ const isSubtopic = p.frontmatter.scope.some((s) => s !== p.slug && s !== "global" && p.slug.startsWith(s + "-"));
458
+ if (isSubtopic) {
459
+ issues.push({
460
+ severity: "warning",
461
+ category: "category-misuse",
462
+ page: `${p.category}/${p.slug}`,
463
+ message: `Likely a reference doc, not a project — slug "${p.slug}" is a sub-topic of scope [${p.frontmatter.scope.join(", ")}]. Consider moving to resources/.`,
464
+ autoFixable: false,
465
+ });
466
+ }
467
+ }
468
+ return issues;
469
+ }
470
+ // -- Auto-fix ---------------------------------------------------------------
471
+ /** Fix secrets: redact API keys, tokens, passwords from page content */
472
+ async function fixSecrets(wikiDir, pages) {
473
+ const fixed = [];
474
+ for (const p of pages) {
475
+ if (!containsSecrets(p.body))
476
+ continue;
477
+ const { text: redacted, redactions } = redactSecrets(p.body);
478
+ if (redactions > 0) {
479
+ const fixedPage = {
480
+ ...p,
481
+ body: redacted,
482
+ frontmatter: {
483
+ ...p.frontmatter,
484
+ updated: new Date().toISOString(),
485
+ },
486
+ };
487
+ await writePage(wikiDir, fixedPage);
488
+ fixed.push({
489
+ severity: "error",
490
+ category: "secrets",
491
+ page: `${p.category}/${p.slug}`,
492
+ message: `Fixed: redacted ${redactions} secret(s)`,
493
+ autoFixable: true,
494
+ });
495
+ }
496
+ }
497
+ return fixed;
498
+ }
499
+ /** Fix link sync: auto-link slug mentions and sync frontmatter.links */
500
+ async function fixLinkSync(wikiDir, pages, allSlugs) {
501
+ const fixed = [];
502
+ for (const p of pages) {
503
+ const originalBody = p.body;
504
+ const originalLinks = [...p.frontmatter.links];
505
+ // Auto-link slug mentions
506
+ const newBody = autoLinkSlugs(p.body, allSlugs, p.slug);
507
+ // Sync frontmatter.links
508
+ const newLinks = syncFrontmatterLinks(newBody);
509
+ // Check if anything changed
510
+ const bodyChanged = newBody !== originalBody;
511
+ const linksChanged = JSON.stringify(newLinks.sort()) !== JSON.stringify(originalLinks.sort());
512
+ if (bodyChanged || linksChanged) {
513
+ const addedLinks = newLinks.filter(l => !originalLinks.includes(l));
514
+ const fixedPage = {
515
+ ...p,
516
+ body: newBody,
517
+ frontmatter: {
518
+ ...p.frontmatter,
519
+ links: newLinks,
520
+ updated: new Date().toISOString(),
521
+ },
522
+ };
523
+ await writePage(wikiDir, fixedPage);
524
+ if (addedLinks.length > 0) {
525
+ fixed.push({
526
+ severity: "warning",
527
+ category: "link-sync",
528
+ page: `${p.category}/${p.slug}`,
529
+ message: `Fixed: auto-linked ${addedLinks.length} slug(s): ${addedLinks.join(", ")}`,
530
+ autoFixable: true,
531
+ });
532
+ }
533
+ if (linksChanged && addedLinks.length === 0) {
534
+ fixed.push({
535
+ severity: "warning",
536
+ category: "link-sync",
537
+ page: `${p.category}/${p.slug}`,
538
+ message: `Fixed: synced frontmatter.links with body [[wikilinks]]`,
539
+ autoFixable: true,
540
+ });
541
+ }
542
+ }
543
+ }
544
+ return fixed;
545
+ }
546
+ /** Fix tag health: normalize all tags via registry */
547
+ async function fixTagHealth(wikiDir, pages) {
548
+ const fixed = [];
549
+ for (const p of pages) {
550
+ const original = [...p.frontmatter.tags];
551
+ const normalized = normalizeTags(p.frontmatter.tags, p.frontmatter.scope);
552
+ if (JSON.stringify(original.sort()) !== JSON.stringify(normalized)) {
553
+ const removed = original.filter(t => !normalized.includes(t.trim().toLowerCase().replace(/\s+/g, "-")));
554
+ p.frontmatter.tags = normalized;
555
+ p.frontmatter.updated = new Date().toISOString();
556
+ await writePage(wikiDir, p);
557
+ fixed.push({
558
+ severity: "warning",
559
+ category: "tag-health",
560
+ page: `${p.category}/${p.slug}`,
561
+ message: `Fixed: normalized tags [${original.join(", ")}] → [${normalized.join(", ")}]`,
562
+ autoFixable: true,
563
+ });
564
+ }
565
+ }
566
+ return fixed;
567
+ }
568
+ /** Fix broken links: remove [[broken]] from body and frontmatter.links */
569
+ async function fixBrokenLinks(wikiDir, pages, allSlugs) {
570
+ const fixed = [];
571
+ for (const p of pages) {
572
+ const bodyLinks = extractWikilinks(p.body);
573
+ const fmLinks = p.frontmatter.links;
574
+ const allLinks = new Set([...bodyLinks, ...fmLinks]);
575
+ const broken = [...allLinks].filter((l) => !allSlugs.has(l));
576
+ if (broken.length === 0)
577
+ continue;
578
+ let newBody = p.body;
579
+ for (const link of broken) {
580
+ newBody = removeWikilink(newBody, link);
581
+ }
582
+ const newFmLinks = fmLinks.filter((l) => allSlugs.has(l));
583
+ const fixedPage = {
584
+ ...p,
585
+ body: newBody,
586
+ frontmatter: { ...p.frontmatter, links: newFmLinks },
587
+ };
588
+ await writePage(wikiDir, fixedPage);
589
+ for (const link of broken) {
590
+ fixed.push({
591
+ severity: "error",
592
+ category: "broken-link",
593
+ page: `${p.category}/${p.slug}`,
594
+ message: `Fixed: removed broken link [[${link}]]`,
595
+ autoFixable: true,
596
+ });
597
+ }
598
+ }
599
+ return fixed;
600
+ }
601
+ /** Fix index drift: rebuild index.md to include all pages */
602
+ async function fixIndexDrift(wikiDir, pages, indexContent) {
603
+ const missing = [];
604
+ for (const p of pages) {
605
+ if (!indexContent.includes(`[[${p.slug}]]`)) {
606
+ missing.push(p);
607
+ }
608
+ }
609
+ if (missing.length === 0)
610
+ return [];
611
+ // Group missing pages by category
612
+ const byCategory = new Map();
613
+ for (const p of missing) {
614
+ const list = byCategory.get(p.category) ?? [];
615
+ list.push(p);
616
+ byCategory.set(p.category, list);
617
+ }
618
+ // Append missing entries to each category section in the index
619
+ let newIndex = indexContent;
620
+ for (const cat of PARA_CATEGORIES) {
621
+ const catPages = byCategory.get(cat);
622
+ if (!catPages || catPages.length === 0)
623
+ continue;
624
+ // Find the category section header
625
+ const headerRe = new RegExp(`(## ${cat.charAt(0).toUpperCase() + cat.slice(1)}[^\n]*\n)`, "i");
626
+ const headerMatch = newIndex.match(headerRe);
627
+ if (headerMatch && headerMatch.index !== undefined) {
628
+ const insertPos = headerMatch.index + headerMatch[0].length;
629
+ const lines = catPages
630
+ .map((p) => `- [[${p.slug}]] — ${p.frontmatter.title}\n`)
631
+ .join("");
632
+ newIndex =
633
+ newIndex.slice(0, insertPos) + lines + newIndex.slice(insertPos);
634
+ }
635
+ }
636
+ // Remove placeholder lines like "_No active projects yet._"
637
+ newIndex = newIndex.replace(/\n_No [^_]*yet\._\n?/g, "\n");
638
+ await writeIndex(wikiDir, newIndex);
639
+ return missing.map((p) => ({
640
+ severity: "warning",
641
+ category: "index-drift",
642
+ page: `${p.category}/${p.slug}`,
643
+ message: `Fixed: added [[${p.slug}]] to index.md`,
644
+ autoFixable: true,
645
+ }));
646
+ }
647
+ /** Fix frontmatter issues: set missing fields to defaults */
648
+ async function fixFrontmatter(wikiDir, pages) {
649
+ const fixed = [];
650
+ for (const p of pages) {
651
+ const fm = p.frontmatter;
652
+ let changed = false;
653
+ if (!fm.title || fm.title === "Untitled") {
654
+ // Derive title from slug
655
+ fm.title = p.slug
656
+ .split("-")
657
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
658
+ .join(" ");
659
+ changed = true;
660
+ fixed.push({
661
+ severity: "error",
662
+ category: "frontmatter",
663
+ page: `${p.category}/${p.slug}`,
664
+ message: `Fixed: set title to "${fm.title}"`,
665
+ autoFixable: true,
666
+ });
667
+ }
668
+ if (fm.scope.length === 0) {
669
+ // Default scope based on category
670
+ if (p.category === "areas") {
671
+ fm.scope = ["global"];
672
+ }
673
+ else if (p.category === "projects") {
674
+ fm.scope = [p.slug];
675
+ }
676
+ else {
677
+ fm.scope = ["global"];
678
+ }
679
+ changed = true;
680
+ fixed.push({
681
+ severity: "warning",
682
+ category: "frontmatter",
683
+ page: `${p.category}/${p.slug}`,
684
+ message: `Fixed: set scope to [${fm.scope.join(", ")}]`,
685
+ autoFixable: true,
686
+ });
687
+ }
688
+ if (changed) {
689
+ fm.updated = new Date().toISOString();
690
+ await writePage(wikiDir, { ...p, frontmatter: fm });
691
+ }
692
+ }
693
+ return fixed;
694
+ }
695
+ /** Fix schema version: run migrations on pages below current version */
696
+ async function fixSchemaVersion(wikiDir, pages) {
697
+ const fixed = [];
698
+ for (const p of pages) {
699
+ const version = p.frontmatter.schemaVersion ?? 1;
700
+ if (version >= CURRENT_SCHEMA_VERSION)
701
+ continue;
702
+ // Read the raw file to get the original frontmatter as a plain object
703
+ const filePath = join(wikiDir, p.category, `${p.slug}.md`);
704
+ const content = await readFile(filePath, "utf-8");
705
+ const { frontmatter: rawFm, body: rawBody } = parseFrontmatter(content);
706
+ const rawObj = rawFm;
707
+ const result = migrateToLatest(rawObj, rawBody);
708
+ // Write back with migrated content
709
+ const migratedFm = result.fm;
710
+ migratedFm.updated = new Date().toISOString();
711
+ await writePage(wikiDir, {
712
+ category: p.category,
713
+ slug: p.slug,
714
+ frontmatter: migratedFm,
715
+ body: result.body,
716
+ });
717
+ fixed.push({
718
+ severity: "warning",
719
+ category: "schema-version",
720
+ page: `${p.category}/${p.slug}`,
721
+ message: `Fixed: migrated schema version ${version} → ${migratedFm.schemaVersion}`,
722
+ autoFixable: true,
723
+ });
724
+ }
725
+ return fixed;
726
+ }
727
+ /** Fix scope drift: add slug to projects/ page scope */
728
+ async function fixScopeDrift(wikiDir, pages) {
729
+ const fixed = [];
730
+ for (const p of pages) {
731
+ if (p.category !== "projects")
732
+ continue;
733
+ if (p.frontmatter.scope.includes(p.slug))
734
+ continue;
735
+ p.frontmatter.scope.push(p.slug);
736
+ p.frontmatter.updated = new Date().toISOString();
737
+ await writePage(wikiDir, p);
738
+ fixed.push({
739
+ severity: "warning",
740
+ category: "scope-drift",
741
+ page: `${p.category}/${p.slug}`,
742
+ message: `Fixed: added "${p.slug}" to scope`,
743
+ autoFixable: true,
744
+ });
745
+ }
746
+ return fixed;
747
+ }
748
+ // -- Main -------------------------------------------------------------------
749
+ export async function lintWiki(wikiDir, options) {
750
+ const staleDays = options?.staleDays ?? 90;
751
+ const autoFix = options?.autoFix ?? true;
752
+ const now = new Date();
753
+ // Load all pages
754
+ const refs = await listPages(wikiDir);
755
+ const pages = [];
756
+ for (const ref of refs) {
757
+ const page = await readPage(wikiDir, ref.category, ref.slug);
758
+ if (page)
759
+ pages.push(page);
760
+ }
761
+ // Build slug set
762
+ const allSlugs = new Set(pages.map((p) => p.slug));
763
+ // Load index and log
764
+ const indexContent = await readIndex(wikiDir);
765
+ let logContent = "";
766
+ try {
767
+ logContent = await readFile(join(wikiDir, "log.md"), "utf-8");
768
+ }
769
+ catch {
770
+ // log.md may not exist
771
+ }
772
+ // Count total outgoing links
773
+ let totalLinks = 0;
774
+ for (const p of pages) {
775
+ totalLinks += extractWikilinks(p.body).length;
776
+ totalLinks += p.frontmatter.links.length;
777
+ }
778
+ // Run all checks
779
+ const issues = [
780
+ ...checkOrphans(pages, allSlugs),
781
+ ...checkBrokenLinks(pages, allSlugs),
782
+ ...checkStale(pages, staleDays, now),
783
+ ...checkScopeDrift(pages),
784
+ ...checkArchiveCandidates(pages, logContent, staleDays, now),
785
+ ...checkMissing(pages, allSlugs),
786
+ ...checkEmptyCategories(pages),
787
+ ...checkFrontmatter(pages),
788
+ ...checkIndexDrift(pages, indexContent),
789
+ ...checkDuplicateSlugs(pages),
790
+ ...checkLinkSync(pages, allSlugs),
791
+ ...checkTagHealth(pages),
792
+ ...checkCategoryMisuse(pages),
793
+ ...checkSecrets(pages),
794
+ ...checkSchemaVersion(pages),
795
+ ];
796
+ // Auto-fix if enabled
797
+ const fixed = [];
798
+ if (autoFix) {
799
+ // Fix secrets first — highest priority
800
+ fixed.push(...(await fixSecrets(wikiDir, pages)));
801
+ fixed.push(...(await fixBrokenLinks(wikiDir, pages, allSlugs)));
802
+ fixed.push(...(await fixLinkSync(wikiDir, pages, allSlugs)));
803
+ fixed.push(...(await fixTagHealth(wikiDir, pages)));
804
+ fixed.push(...(await fixIndexDrift(wikiDir, pages, indexContent)));
805
+ fixed.push(...(await fixFrontmatter(wikiDir, pages)));
806
+ fixed.push(...(await fixScopeDrift(wikiDir, pages)));
807
+ fixed.push(...(await fixSchemaVersion(wikiDir, pages)));
808
+ }
809
+ // Recount broken links for stats
810
+ const brokenLinkCount = issues.filter((i) => i.category === "broken-link").length;
811
+ const orphanCount = issues.filter((i) => i.category === "orphan").length;
812
+ const stats = makeStats(pages, totalLinks, brokenLinkCount, orphanCount, logContent);
813
+ // Remove auto-fixed issues from the reported issues list
814
+ const fixedKeys = new Set(fixed.map((f) => `${f.category}:${f.page ?? ""}:${f.message}`));
815
+ // Filter: keep issues that weren't fixed, or that aren't fixable
816
+ const remainingIssues = autoFix
817
+ ? issues.filter((i) => {
818
+ if (!i.autoFixable)
819
+ return true;
820
+ // For fixable issues, check if a corresponding fix was applied
821
+ // Match by category + page
822
+ return !fixed.some((f) => f.category === i.category && f.page === i.page);
823
+ })
824
+ : issues;
825
+ return { issues: remainingIssues, fixed, stats };
826
+ }
827
+ //# sourceMappingURL=lint.js.map