Skip to content

Commit d7a71f1

Browse files
kuworkMrLesk
authored andcommitted
BACK-477 - Add-wiki-online-editing-file-change-monitoring-and-folder-based-page-creation
1 parent 98d866a commit d7a71f1

11 files changed

Lines changed: 931 additions & 115 deletions

File tree

.codex/skills/llm-wiki-for-backlog/SKILL.md

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ The wiki does not maintain a separate schema file. All wiki-related guidelines,
106106

107107
### Two Navigation Files
108108

109-
- **`index.md`** — Content-oriented catalog of every wiki page with one-line summaries, organized by type. The LLM reads this FIRST on any operation. Updated on every ingest.
109+
- **`index.md`** — Content-oriented catalog of every wiki page with one-line summaries, organized by labels. The LLM reads this FIRST on any operation. Updated on every ingest.
110110
- **`log.md`** — Chronological, append-only timeline. Format: `## [YYYY-MM-DD HH:mm:ss] {op} | {title}`. Parseable with `grep "^## \[" log.md`. Shows what happened when. The detailed timestamp enables git-aware incremental ingestion.
111111

112112
---
@@ -191,7 +191,7 @@ Before building the wiki, verify that Backlog.md has been initialized in the pro
191191
7. **Append to `wiki/log.md`** with full timestamp `## [YYYY-MM-DD HH:mm:ss] batch-ingest | {summary}`
192192

193193
**Page conventions:**
194-
- Every page: YAML frontmatter with `type`, `title`, `updated` at minimum
194+
- Every page: YAML frontmatter with `title`, `created_date`, `updated_date` at minimum; `labels` as optional array of tags (e.g. `source`, `concept`, `entity`, `comparison`)
195195
- Source pages include `source_path` linking back to original backlog file
196196
- All cross-references: `[[wikilinks]]`
197197
- Filenames: lowercase-with-hyphens
@@ -232,7 +232,11 @@ Before building the wiki, verify that Backlog.md has been initialized in the pro
232232

233233
### Essential Rules
234234
- **`[[Wikilinks]]`** for ALL cross-references within the wiki — including `index.md` tables
235-
- **YAML frontmatter** on every page: `type`, `title`, `updated` at minimum
235+
- **YAML frontmatter** on every wiki page at minimum:
236+
- `title` — page title
237+
- `created_date` — set on creation (`yyyy-MM-dd HH:mm`)
238+
- `updated_date` — updated on every save (`yyyy-MM-dd HH:mm`)
239+
- `labels` — optional array of tags for categorization (e.g. `source`, `concept`, `entity`, `comparison`)
236240
- **Backlog source folders are immutable** — the LLM never writes to `tasks/`, `docs/`, `decisions/`, etc.
237241
- **Filenames:** lowercase-with-hyphens
238242
- **Exclude `wiki/` and `wiki_output/` from ingestion** — absolute rule to prevent recursion
@@ -245,10 +249,10 @@ Before building the wiki, verify that Backlog.md has been initialized in the pro
245249
**In `index.md` tables, use wikilinks in the first column:**
246250

247251
```markdown
248-
| File | Title | Type | Desc |
249-
|------|-------|------|------|
250-
| [[sources/task-1-offline-encryption]] | TASK-1: Offline Encryption | Epic | Offline local encryption mechanism |
251-
| [[concepts/keyvault]] | KeyVault | Concept | Core encryption key management |
252+
| File | Title | Labels | Desc |
253+
|------|-------|--------|------|
254+
| [[sources/task-1-offline-encryption]] | TASK-1: Offline Encryption | source, epic | Offline local encryption mechanism |
255+
| [[concepts/keyvault]] | KeyVault | concept | Core encryption key management |
252256
```
253257

254258
**In page bodies (sources, concepts, entities), use wikilinks in Related sections:**
@@ -265,7 +269,7 @@ Before building the wiki, verify that Backlog.md has been initialized in the pro
265269

266270
**❌ NEVER use standard Markdown links for internal wiki pages:**
267271
```markdown
268-
| Source | Type | Summary |
272+
| Source | Labels | Summary |
269273
|--------|------|---------|
270274
| [source-security-module](sources/source-security-module.md) | source | Security module summary |
271275

@@ -275,7 +279,7 @@ Before building the wiki, verify that Backlog.md has been initialized in the pro
275279

276280
**✅ ALWAYS use wikilinks:**
277281
```markdown
278-
| Source | Type | Summary |
282+
| Source | Labels | Summary |
279283
|--------|------|---------|
280284
| [[sources/source-security-module]] | source | Security module summary |
281285

@@ -359,7 +363,7 @@ Key behaviors:
359363
- **CRITICAL:** In page bodies (sources, concepts, entities), Related Concepts / Related Sources / Related Entities sections must also use `[[path/to/file]]`, not `[text](path.md)`
360364
- Example: `- [[concepts/keyvault]]` — NOT `- [KeyVault](concepts/keyvault.md)`
361365
- Append-only for `wiki/log.md`
362-
- YAML frontmatter on every wiki page: `type`, `title`, `updated`
366+
- YAML frontmatter on every wiki page at minimum: `title`, `created_date`, `updated_date`; `labels` as optional array of tags
363367
- Filenames: lowercase-with-hyphens
364368

365369
### Operations

AGENTS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,11 @@ The LLM reads from the following backlog folders as raw input. These are immutab
142142
- **CRITICAL:** In page bodies (sources, concepts, entities), Related Concepts / Related Sources / Related Entities sections must also use `[[path/to/file]]`, not `[text](path.md)`
143143
- Example: `- [[concepts/keyvault]]` — NOT `- [KeyVault](concepts/keyvault.md)`
144144
- Append-only for `wiki/log.md`
145-
- YAML frontmatter on every wiki page: `type`, `title`, `updated`
145+
- YAML frontmatter on every wiki page at minimum:
146+
- `title` — page title
147+
- `created_date` — set on creation (`yyyy-MM-dd HH:mm`)
148+
- `updated_date` — updated on every save (`yyyy-MM-dd HH:mm`)
149+
- `labels` — optional array of tags for categorization (e.g. `source`, `concept`, `entity`, `comparison`)
146150
- Filenames: lowercase-with-hyphens
147151

148152
### Operations

src/core/content-store.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type FSWatcher, watch } from "node:fs";
22
import { readdir, stat } from "node:fs/promises";
3-
import { basename, join, relative, sep } from "node:path";
3+
import { basename, dirname, join, relative, sep } from "node:path";
44
import type { FileSystem } from "../file-system/operations.ts";
55
import { parseDecision, parseDocument, parseTask } from "../markdown/parser.ts";
66
import type { Decision, Document, Task, TaskListFilter } from "../types/index.ts";
@@ -246,6 +246,14 @@ export class ContentStore {
246246
}
247247
}
248248

249+
try {
250+
this.watchers.push(this.createWikiWatcher());
251+
} catch (error) {
252+
if (process.env.DEBUG) {
253+
console.error("Failed to initialize wiki watcher", error);
254+
}
255+
}
256+
249257
try {
250258
const configWatcher = this.createConfigWatcher();
251259
if (configWatcher) {
@@ -307,6 +315,24 @@ export class ContentStore {
307315
}
308316
}
309317

318+
private createWikiWatcher(): WatchHandle {
319+
const wikiRoot = join(dirname(this.filesystem.docsDir), "wiki");
320+
const watcher: FSWatcher = watch(wikiRoot, { recursive: true }, (_eventType, filename) => {
321+
const file = this.normalizeFilename(filename);
322+
if (!file || file.includes("wiki_output")) return;
323+
this.enqueue(async () => {
324+
this.notify("tasks");
325+
});
326+
});
327+
this.attachWatcherErrorHandler(watcher, "wiki");
328+
329+
return {
330+
stop() {
331+
watcher.close();
332+
},
333+
};
334+
}
335+
310336
private createTaskWatcher(): WatchHandle {
311337
const tasksDir = this.filesystem.tasksDir;
312338
const watcher: FSWatcher = watch(tasksDir, { recursive: false }, (eventType, filename) => {

src/file-system/operations.ts

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { mkdir, readdir, rename, stat, unlink } from "node:fs/promises";
2-
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
2+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
33
import matter from "gray-matter";
44
import lockfile from "proper-lockfile";
55
import { DEFAULT_DIRECTORIES, DEFAULT_FILES, DEFAULT_STATUSES, FALLBACK_STATUS } from "../constants/index.ts";
@@ -1738,6 +1738,127 @@ ${description || `Milestone: ${title}`}`,
17381738
};
17391739
}
17401740

1741+
async saveWikiPage(pagePath: string, content: string, title?: string, labels?: string[]): Promise<void> {
1742+
const wikiRoot = resolve(join(this.resolvedBacklogDir, "wiki"));
1743+
const normalizedPath = pagePath.endsWith(".md") ? pagePath : `${pagePath}.md`;
1744+
const filePath = resolve(join(wikiRoot, normalizedPath));
1745+
1746+
// Directory traversal containment check
1747+
const rel = relative(wikiRoot, filePath);
1748+
const isInside = !rel.startsWith("..") && !isAbsolute(rel);
1749+
if (!isInside) {
1750+
throw new Error("Invalid wiki path");
1751+
}
1752+
1753+
await this.ensureDirectoryExists(dirname(filePath));
1754+
1755+
// Preserve existing frontmatter, update title, labels, and set updated_date
1756+
let frontmatter: Record<string, unknown> = {};
1757+
if (await Bun.file(filePath).exists()) {
1758+
const existing = await Bun.file(filePath).text();
1759+
const parsed = parseMarkdown(existing);
1760+
frontmatter = (parsed.frontmatter as Record<string, unknown>) || {};
1761+
}
1762+
if (title !== undefined) {
1763+
frontmatter.title = title;
1764+
}
1765+
if (labels !== undefined) {
1766+
frontmatter.labels = labels;
1767+
}
1768+
const updatedDate = new Date().toISOString().slice(0, 16).replace("T", " ");
1769+
frontmatter.updated_date = updatedDate;
1770+
1771+
const fileContent = Object.keys(frontmatter).length > 0 ? matter.stringify(content, frontmatter) : content;
1772+
await Bun.write(filePath, fileContent);
1773+
}
1774+
1775+
async createWikiPage(pagePath: string, content = "", labels?: string[]): Promise<string> {
1776+
const wikiRoot = resolve(join(this.resolvedBacklogDir, "wiki"));
1777+
const normalizedPath = pagePath.endsWith(".md") ? pagePath : `${pagePath}.md`;
1778+
const filePath = resolve(join(wikiRoot, normalizedPath));
1779+
1780+
// Directory traversal containment check
1781+
const rel = relative(wikiRoot, filePath);
1782+
const isInside = !rel.startsWith("..") && !isAbsolute(rel);
1783+
if (!isInside) {
1784+
throw new Error("Invalid wiki path");
1785+
}
1786+
1787+
// Prevent overwriting existing files
1788+
if (await Bun.file(filePath).exists()) {
1789+
throw new Error("Wiki page already exists");
1790+
}
1791+
1792+
await this.ensureDirectoryExists(dirname(filePath));
1793+
const title = basename(normalizedPath, ".md");
1794+
const defaultContent = content || `# ${title}\n\n`;
1795+
const createdDate = new Date().toISOString().slice(0, 16).replace("T", " ");
1796+
const frontmatter: Record<string, unknown> = { title, created_date: createdDate };
1797+
if (labels !== undefined && labels.length > 0) {
1798+
frontmatter.labels = labels;
1799+
}
1800+
const fileContent = matter.stringify(defaultContent, frontmatter);
1801+
await Bun.write(filePath, fileContent);
1802+
1803+
return normalizedPath;
1804+
}
1805+
1806+
async createWikiFolder(folderPath: string): Promise<string> {
1807+
const wikiRoot = resolve(join(this.resolvedBacklogDir, "wiki"));
1808+
const dirPath = resolve(join(wikiRoot, folderPath));
1809+
1810+
// Directory traversal containment check
1811+
const rel = relative(wikiRoot, dirPath);
1812+
const isInside = !rel.startsWith("..") && !isAbsolute(rel);
1813+
if (!isInside) {
1814+
throw new Error("Invalid wiki path");
1815+
}
1816+
1817+
// Prevent overwriting existing files or directories
1818+
if (await Bun.file(dirPath).exists()) {
1819+
throw new Error("Wiki folder already exists");
1820+
}
1821+
1822+
await mkdir(dirPath, { recursive: true });
1823+
return folderPath;
1824+
}
1825+
1826+
async renameWikiItem(oldPath: string, newPath: string): Promise<string> {
1827+
const wikiRoot = resolve(join(this.resolvedBacklogDir, "wiki"));
1828+
const oldFullPath = resolve(join(wikiRoot, oldPath));
1829+
const newFullPath = resolve(join(wikiRoot, newPath));
1830+
1831+
// Directory traversal containment check for both paths
1832+
const oldRel = relative(wikiRoot, oldFullPath);
1833+
const newRel = relative(wikiRoot, newFullPath);
1834+
const oldIsInside = !oldRel.startsWith("..") && !isAbsolute(oldRel);
1835+
const newIsInside = !newRel.startsWith("..") && !isAbsolute(newRel);
1836+
if (!oldIsInside || !newIsInside) {
1837+
throw new Error("Invalid wiki path");
1838+
}
1839+
1840+
// Source must exist (use stat to handle both files and directories)
1841+
try {
1842+
await stat(oldFullPath);
1843+
} catch {
1844+
throw new Error("Item not found");
1845+
}
1846+
1847+
// Destination must not exist
1848+
try {
1849+
await stat(newFullPath);
1850+
throw new Error("Destination already exists");
1851+
} catch (err: any) {
1852+
if (err.message === "Destination already exists") throw err;
1853+
// stat threw because file doesn't exist — that's what we want
1854+
}
1855+
1856+
// Ensure parent directory of destination exists
1857+
await this.ensureDirectoryExists(dirname(newFullPath));
1858+
await rename(oldFullPath, newFullPath);
1859+
return newPath;
1860+
}
1861+
17411862
private normalizeDefinitionOfDone(definitionOfDone: unknown): string[] | undefined {
17421863
if (!Array.isArray(definitionOfDone)) {
17431864
return undefined;

src/server/index.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,9 @@ export class BacklogServer {
353353
"/api/wiki/tree": {
354354
GET: async () => await this.handleGetWikiTree(),
355355
},
356+
"/api/wiki": {
357+
POST: async (req: Request) => await this.handleCreateWiki(req),
358+
},
356359
"/api/wiki/*": {
357360
GET: async (req: Request) => {
358361
const url = new URL(req.url);
@@ -361,6 +364,20 @@ export class BacklogServer {
361364
const path = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : "";
362365
return await this.handleGetWikiPage(path);
363366
},
367+
PUT: async (req: Request) => {
368+
const url = new URL(req.url);
369+
const pathname = decodeURIComponent(url.pathname || "");
370+
const prefix = "/api/wiki/";
371+
const path = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : "";
372+
return await this.handleUpdateWiki(req, path);
373+
},
374+
PATCH: async (req: Request) => {
375+
const url = new URL(req.url);
376+
const pathname = decodeURIComponent(url.pathname || "");
377+
const prefix = "/api/wiki/";
378+
const path = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : "";
379+
return await this.handleRenameWiki(req, path);
380+
},
364381
},
365382
"/api/decisions": {
366383
GET: async () => await this.handleListDecisions(),
@@ -1114,6 +1131,84 @@ export class BacklogServer {
11141131
}
11151132
}
11161133

1134+
private async handleUpdateWiki(req: Request, pagePath: string): Promise<Response> {
1135+
try {
1136+
const body = await req.json();
1137+
const content = typeof body?.content === "string" ? body.content : undefined;
1138+
const title = typeof body?.title === "string" ? body.title : undefined;
1139+
const labels = Array.isArray(body?.labels) ? body.labels.map(String) : undefined;
1140+
if (typeof content !== "string") {
1141+
return Response.json({ error: "Content is required" }, { status: 400 });
1142+
}
1143+
1144+
await this.core.filesystem.saveWikiPage(pagePath, content, title, labels);
1145+
return Response.json({ success: true });
1146+
} catch (error) {
1147+
const message = error instanceof Error ? error.message : "Failed to update wiki";
1148+
console.error("Error updating wiki:", error);
1149+
return Response.json({ error: message }, { status: 500 });
1150+
}
1151+
}
1152+
1153+
private async handleCreateWiki(req: Request): Promise<Response> {
1154+
try {
1155+
const body = await req.json();
1156+
const path = typeof body?.path === "string" ? body.path.trim() : undefined;
1157+
const content = typeof body?.content === "string" ? body.content : undefined;
1158+
const isFolder = body?.isFolder === true;
1159+
const labels = Array.isArray(body?.labels) ? body.labels.map(String) : undefined;
1160+
1161+
if (!path) {
1162+
return Response.json({ error: "Path is required" }, { status: 400 });
1163+
}
1164+
if (path.includes("..") || path.startsWith("/") || path.startsWith("\\")) {
1165+
return Response.json({ error: "Invalid path" }, { status: 400 });
1166+
}
1167+
1168+
const createdPath = isFolder
1169+
? await this.core.filesystem.createWikiFolder(path)
1170+
: await this.core.filesystem.createWikiPage(path, content, labels);
1171+
return Response.json({ success: true, path: createdPath }, { status: 201 });
1172+
} catch (error) {
1173+
const message = error instanceof Error ? error.message : "Failed to create wiki page";
1174+
if (message.includes("already exists")) {
1175+
return Response.json({ error: message }, { status: 409 });
1176+
}
1177+
console.error("Error creating wiki:", error);
1178+
return Response.json({ error: message }, { status: 500 });
1179+
}
1180+
}
1181+
1182+
private async handleRenameWiki(req: Request, oldPath: string): Promise<Response> {
1183+
try {
1184+
const body = await req.json();
1185+
const newPath = typeof body?.newPath === "string" ? body.newPath.trim() : undefined;
1186+
1187+
if (!oldPath || !newPath) {
1188+
return Response.json({ error: "Old path and new path are required" }, { status: 400 });
1189+
}
1190+
if (oldPath.includes("..") || oldPath.startsWith("/") || oldPath.startsWith("\\")) {
1191+
return Response.json({ error: "Invalid old path" }, { status: 400 });
1192+
}
1193+
if (newPath.includes("..") || newPath.startsWith("/") || newPath.startsWith("\\")) {
1194+
return Response.json({ error: "Invalid new path" }, { status: 400 });
1195+
}
1196+
1197+
const renamedPath = await this.core.filesystem.renameWikiItem(oldPath, newPath);
1198+
return Response.json({ success: true, path: renamedPath }, { status: 200 });
1199+
} catch (error) {
1200+
const message = error instanceof Error ? error.message : "Failed to rename wiki item";
1201+
if (message.includes("not found")) {
1202+
return Response.json({ error: message }, { status: 404 });
1203+
}
1204+
if (message.includes("already exists")) {
1205+
return Response.json({ error: message }, { status: 409 });
1206+
}
1207+
console.error("Error renaming wiki:", error);
1208+
return Response.json({ error: message }, { status: 500 });
1209+
}
1210+
}
1211+
11171212
private async handleCreateDoc(req: Request): Promise<Response> {
11181213
try {
11191214
const body = await req.json();

0 commit comments

Comments
 (0)