chapterhouse 0.3.14 → 0.3.15
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/dist/config.js +11 -0
- package/dist/copilot/system-message.js +7 -0
- package/dist/copilot/tools.js +63 -47
- package/dist/daemon.js +6 -0
- package/dist/wiki/fs.js +5 -0
- package/dist/wiki/index-manager.js +92 -17
- package/dist/wiki/index-manager.test.js +19 -0
- package/dist/wiki/migrate-topics.js +132 -0
- package/dist/wiki/migrate-topics.test.js +57 -0
- package/dist/wiki/topic-structure.js +167 -0
- package/dist/wiki/topic-structure.test.js +74 -0
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -37,6 +37,7 @@ const configSchema = z.object({
|
|
|
37
37
|
TEAM_CHAPTERHOUSE_TOKEN: z.string().optional(),
|
|
38
38
|
TEAM_WIKI_CACHE_TTL_MINUTES: z.string().optional(),
|
|
39
39
|
TEAM_WIKI_PATHS: z.string().optional(),
|
|
40
|
+
WIKI_ENTITY_CATEGORIES: z.string().optional(),
|
|
40
41
|
TEAMS_WEBHOOK_URL: z.string().optional(),
|
|
41
42
|
TEAMS_NOTIFICATIONS_ENABLED: z.string().optional(),
|
|
42
43
|
COPILOT_TOKEN: z.string().optional(),
|
|
@@ -51,6 +52,7 @@ const configSchema = z.object({
|
|
|
51
52
|
export const DEFAULT_MODEL = "claude-sonnet-4.6";
|
|
52
53
|
export const DEFAULT_TEAM_WIKI_CACHE_TTL_MINUTES = 60;
|
|
53
54
|
export const DEFAULT_TEAM_WIKI_PATHS = ["pages/team", "pages/okrs", "pages/kpis", "pages/shared"];
|
|
55
|
+
export const DEFAULT_ENTITY_CATEGORIES = ["projects", "people", "orgs", "tools", "topics", "areas"];
|
|
54
56
|
export const DEFAULT_STANDUP_TIME = "09:00";
|
|
55
57
|
export const DEFAULT_ADO_ORG = "";
|
|
56
58
|
export const DEFAULT_ADO_PROJECT = "";
|
|
@@ -128,6 +130,13 @@ function parseTeamWikiPaths(rawValue) {
|
|
|
128
130
|
.filter(Boolean);
|
|
129
131
|
return paths.length > 0 ? paths : [...DEFAULT_TEAM_WIKI_PATHS];
|
|
130
132
|
}
|
|
133
|
+
function parseEntityCategories(rawValue) {
|
|
134
|
+
const cats = (rawValue || DEFAULT_ENTITY_CATEGORIES.join(","))
|
|
135
|
+
.split(",")
|
|
136
|
+
.map((value) => value.trim().toLowerCase().replace(/[^a-z0-9-]/g, ""))
|
|
137
|
+
.filter(Boolean);
|
|
138
|
+
return cats.length > 0 ? [...new Set(cats)] : [...DEFAULT_ENTITY_CATEGORIES];
|
|
139
|
+
}
|
|
131
140
|
function resolveConfiguredApiToken(envToken, { apiTokenPath = API_TOKEN_PATH, exists = existsSync, readFile = readFileSync, } = {}) {
|
|
132
141
|
const trimmedEnvToken = envToken?.trim();
|
|
133
142
|
if (trimmedEnvToken) {
|
|
@@ -213,6 +222,7 @@ export function parseRuntimeConfig(env, options = {}) {
|
|
|
213
222
|
teamChapterhouseToken: raw.TEAM_CHAPTERHOUSE_TOKEN?.trim() || "",
|
|
214
223
|
teamWikiCacheTtlMinutes: parsedTeamWikiCacheTtlMinutes,
|
|
215
224
|
teamWikiPaths: parseTeamWikiPaths(raw.TEAM_WIKI_PATHS),
|
|
225
|
+
wikiEntityCategories: parseEntityCategories(raw.WIKI_ENTITY_CATEGORIES),
|
|
216
226
|
teamsWebhookUrl,
|
|
217
227
|
teamsNotificationsEnabled,
|
|
218
228
|
apiRateLimitWindowMs,
|
|
@@ -253,6 +263,7 @@ export const config = {
|
|
|
253
263
|
teamChapterhouseToken: runtimeConfig.teamChapterhouseToken,
|
|
254
264
|
teamWikiCacheTtlMinutes: runtimeConfig.teamWikiCacheTtlMinutes,
|
|
255
265
|
teamWikiPaths: runtimeConfig.teamWikiPaths,
|
|
266
|
+
wikiEntityCategories: runtimeConfig.wikiEntityCategories,
|
|
256
267
|
teamsWebhookUrl: runtimeConfig.teamsWebhookUrl,
|
|
257
268
|
teamsNotificationsEnabled: runtimeConfig.teamsNotificationsEnabled,
|
|
258
269
|
apiRateLimitWindowMs: runtimeConfig.apiRateLimitWindowMs,
|
|
@@ -115,6 +115,13 @@ You can delegate **multiple tasks simultaneously**. Different agents can work in
|
|
|
115
115
|
|
|
116
116
|
**Past conversations**: Daily conversation summaries are auto-written to \`pages/conversations/YYYY-MM-DD.md\`. When the user references something from earlier ("what did we decide about X", "remember when we…", "the thing we discussed yesterday"), call \`wiki_search\` (or \`recall\`) — don't guess from your own context, since older turns may have been compacted out.
|
|
117
117
|
|
|
118
|
+
**Wiki structure** — the wiki enforces a topic layout, so put things in the right place:
|
|
119
|
+
- **Entity categories** (\`projects\`, \`people\`, \`orgs\`, \`tools\`, \`topics\`, \`areas\`): every named thing gets its own directory \`pages/<category>/<topic-slug>/\`. The topic's overview lives at \`pages/<category>/<topic-slug>/index.md\`; related sub-pages ("facets") go alongside it, e.g. \`pages/projects/chapterhouse/decisions.md\`, \`pages/projects/chapterhouse/feature-ideas.md\`. Exactly one topic level deep; lowercase-slug names only.
|
|
120
|
+
- **Flat categories** (\`preferences\`, \`facts\`, \`routines\`): a single file each, e.g. \`pages/preferences.md\`.
|
|
121
|
+
- **Decisions** are always recorded against an entity, never as a standalone page: a decision about a project goes to \`pages/projects/<topic>/decisions.md\`. With \`remember\`, pass \`category: "decision"\` plus \`about\` (which entity category) and \`entity\`.
|
|
122
|
+
- With \`remember\`, pass \`entity\` for entity categories (and \`facet\` to target a sub-page). With \`wiki_update\`, give the full canonical path — bad paths are rejected with a suggested correction; just retry with the suggestion.
|
|
123
|
+
- If the structure ever looks wrong, call \`wiki_rebuild_index\` to regenerate the index from disk.
|
|
124
|
+
|
|
118
125
|
**Learning workflow**: When the user asks you to do something you don't have a skill for:
|
|
119
126
|
1. **Search skills.sh first**: Use the find-skills skill to search for existing community skills.
|
|
120
127
|
2. **Present what you found**: Tell the user the skill name, what it does, and its security status.
|
package/dist/copilot/tools.js
CHANGED
|
@@ -11,6 +11,7 @@ import { getRouterConfig, updateRouterConfig } from "./router.js";
|
|
|
11
11
|
import { ensureWikiStructure, readPage, writePage, deletePage, listPages, writeRawSource, listSources, assertPagePath } from "../wiki/fs.js";
|
|
12
12
|
import { searchIndex, addToIndex, removeFromIndex, parseIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
|
|
13
13
|
import { appendLog } from "../wiki/log-manager.js";
|
|
14
|
+
import { getCategoryDir, topicPagePath, slugify, entityCategories, FLAT_CATEGORIES } from "../wiki/topic-structure.js";
|
|
14
15
|
import { withWikiWrite } from "../wiki/lock.js";
|
|
15
16
|
import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
|
|
16
17
|
import { getAgentRegistry, getAgent, createEphemeralAgentSession, getAgentSessionStatus, getTask, registerTask, completeTask, failTask, createAgentFile, removeAgentFile, loadAgents, } from "./agents.js";
|
|
@@ -20,17 +21,6 @@ import { TeamPushClient } from "../integrations/team-push.js";
|
|
|
20
21
|
import { OKRMapper, parseOKRPageContent } from "./okr-mapper.js";
|
|
21
22
|
import { childLogger } from "../util/logger.js";
|
|
22
23
|
const log = childLogger("tools");
|
|
23
|
-
function getCategoryDir(category) {
|
|
24
|
-
const map = {
|
|
25
|
-
person: "people",
|
|
26
|
-
project: "projects",
|
|
27
|
-
preference: "preferences",
|
|
28
|
-
fact: "facts",
|
|
29
|
-
routine: "routines",
|
|
30
|
-
decision: "decisions",
|
|
31
|
-
};
|
|
32
|
-
return map[category] || category;
|
|
33
|
-
}
|
|
34
24
|
/** Escape a string for safe inclusion as a single-line YAML scalar value. */
|
|
35
25
|
function yamlEscape(value) {
|
|
36
26
|
// Always quote and escape backslashes, double quotes, and newlines.
|
|
@@ -650,55 +640,73 @@ export function createTools(deps) {
|
|
|
650
640
|
}),
|
|
651
641
|
// ----- Wiki-backed memory facades (preserve existing remember/recall/forget UX) -----
|
|
652
642
|
defineTool("remember", {
|
|
653
|
-
description: "Save a fact, preference, or detail to the wiki. Routes to
|
|
643
|
+
description: "Save a fact, preference, or detail to the wiki. Routes to topic pages automatically. " +
|
|
654
644
|
"Use for discrete facts ('The team prefers dark mode', 'Project uses Vercel'). " +
|
|
645
|
+
"Entity categories (project/person/org/tool/topic/area) are filed under pages/<category>/<topic>/index.md; " +
|
|
646
|
+
"pass `entity` for those, and `facet` to file under a sub-page like pages/projects/chapterhouse/feature-ideas.md. " +
|
|
647
|
+
"A `decision` is always recorded against an entity: pass both `about` (which entity category) and `entity`; " +
|
|
648
|
+
"it lands at pages/<about>/<entity>/decisions.md. " +
|
|
655
649
|
"For richer knowledge pages, use wiki_update instead.",
|
|
656
650
|
parameters: z.object({
|
|
657
|
-
category: z.enum(["preference", "fact", "project", "person", "routine", "decision"])
|
|
658
|
-
.describe("Category
|
|
651
|
+
category: z.enum(["preference", "fact", "project", "person", "routine", "decision", "org", "tool", "topic", "area"])
|
|
652
|
+
.describe("Category. Entity categories (need `entity`): project (codebase/repo), person (people), org (companies/teams), tool (software/technologies), topic (knowledge areas), area (areas of responsibility). decision (a choice made — needs `about` + `entity`). Flat categories: preference (likes/dislikes/settings), fact (general knowledge), routine (schedules/habits)."),
|
|
659
653
|
content: z.string().describe("The thing to remember — a concise, self-contained statement"),
|
|
660
|
-
entity: z.string().optional().describe("
|
|
654
|
+
entity: z.string().optional().describe("Required for entity categories and for decisions: the specific topic this is about (e.g. 'chapterhouse', 'vercel', 'Brian'). Routes to pages/<category>/<topic-slug>/…"),
|
|
655
|
+
about: z.enum(["project", "person", "org", "tool", "topic", "area"]).optional()
|
|
656
|
+
.describe("Required only when category='decision': which entity category the decision concerns. The decision is filed at pages/<about>/<entity>/decisions.md."),
|
|
657
|
+
facet: z.string().optional().describe("Optional sub-page within the topic directory (e.g. 'feature-ideas', 'architecture'). Only meaningful with an entity category + `entity`. Defaults to the topic's index page. (For decisions use `about` instead.)"),
|
|
661
658
|
related: z.array(z.string()).optional().describe("Wiki page paths this connects to, for cross-referencing"),
|
|
662
659
|
}),
|
|
663
660
|
handler: async (args) => {
|
|
664
661
|
return withWikiWrite(async () => {
|
|
665
662
|
ensureWikiStructure();
|
|
666
663
|
const now = new Date().toISOString().slice(0, 10);
|
|
667
|
-
|
|
664
|
+
const titleCase = (s) => s.split(/[-_\s]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
665
|
+
// Routing: code-authoritative slugification and page lookup.
|
|
668
666
|
let pagePath;
|
|
669
667
|
let title;
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
// Check for existing page with fuzzy match before creating new
|
|
675
|
-
const existingPages = searchIndex(args.entity, 5);
|
|
676
|
-
const existingMatch = existingPages.find((p) => {
|
|
677
|
-
const pSlug = p.path.split("/").pop()?.replace(".md", "") || "";
|
|
678
|
-
return pSlug === slug || p.title.toLowerCase() === args.entity.toLowerCase();
|
|
679
|
-
});
|
|
680
|
-
if (existingMatch) {
|
|
681
|
-
pagePath = existingMatch.path;
|
|
682
|
-
title = existingMatch.title;
|
|
668
|
+
let routedCategoryDir; // the directory under pages/ this ends up in
|
|
669
|
+
if (args.category === "decision") {
|
|
670
|
+
if (!args.about || !args.entity) {
|
|
671
|
+
return `A decision needs both 'about' (the entity category it concerns — one of: ${entityCategories().join(", ")}) and 'entity' (the specific one, e.g. "chapterhouse"). It will be filed at pages/<about>/<entity>/decisions.md.`;
|
|
683
672
|
}
|
|
684
|
-
|
|
685
|
-
|
|
673
|
+
routedCategoryDir = getCategoryDir(args.about);
|
|
674
|
+
if (!entityCategories().includes(routedCategoryDir)) {
|
|
675
|
+
return `'${args.about}' isn't a known entity category. Use one of: ${entityCategories().join(", ")}.`;
|
|
686
676
|
}
|
|
677
|
+
pagePath = topicPagePath(args.about, args.entity, "decisions");
|
|
678
|
+
const existingMatch = searchIndex(args.entity, 5).find((p) => p.path === pagePath);
|
|
679
|
+
title = existingMatch ? existingMatch.title : `${titleCase(args.entity)} — Decisions`;
|
|
687
680
|
}
|
|
688
681
|
else {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
682
|
+
routedCategoryDir = getCategoryDir(args.category);
|
|
683
|
+
const isFlat = FLAT_CATEGORIES.includes(routedCategoryDir);
|
|
684
|
+
if (!isFlat) {
|
|
685
|
+
if (!args.entity) {
|
|
686
|
+
return `The '${args.category}' category needs an 'entity' (the specific ${args.category} this is about) so it can be filed under pages/${routedCategoryDir}/<topic>/. Re-call remember with an entity, e.g. entity: "chapterhouse".`;
|
|
687
|
+
}
|
|
688
|
+
const facet = args.facet ? slugify(args.facet) : "index";
|
|
689
|
+
pagePath = topicPagePath(args.category, args.entity, facet);
|
|
690
|
+
// Reuse an existing page if the index already has one at (or matching) this path.
|
|
691
|
+
const existingPages = searchIndex(args.entity, 5);
|
|
692
|
+
const existingMatch = existingPages.find((p) => p.path === pagePath ||
|
|
693
|
+
(facet === "index" && p.title.toLowerCase() === args.entity.toLowerCase() && p.path.startsWith(`pages/${routedCategoryDir}/`)));
|
|
694
|
+
if (existingMatch) {
|
|
695
|
+
pagePath = existingMatch.path;
|
|
696
|
+
title = existingMatch.title;
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
const topicTitle = titleCase(args.entity);
|
|
700
|
+
title = facet === "index" ? topicTitle : `${topicTitle} — ${titleCase(facet)}`;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
pagePath = `pages/${routedCategoryDir}.md`;
|
|
705
|
+
title = titleCase(routedCategoryDir);
|
|
706
|
+
}
|
|
699
707
|
}
|
|
700
708
|
// Defense-in-depth: pagePath is constructed from controlled parts but
|
|
701
|
-
// assertPagePath
|
|
709
|
+
// assertPagePath also enforces the topic-structure rules.
|
|
702
710
|
assertPagePath(pagePath);
|
|
703
711
|
const existing = readPage(pagePath);
|
|
704
712
|
if (existing) {
|
|
@@ -753,7 +761,7 @@ export function createTools(deps) {
|
|
|
753
761
|
"Use when you need to look up something the user told you, or when asked 'do you remember...?'",
|
|
754
762
|
parameters: z.object({
|
|
755
763
|
keyword: z.string().optional().describe("Search term to match against wiki pages"),
|
|
756
|
-
category: z.enum(["preference", "fact", "project", "person", "routine", "decision"]).optional()
|
|
764
|
+
category: z.enum(["preference", "fact", "project", "person", "routine", "decision", "org", "tool", "topic", "area"]).optional()
|
|
757
765
|
.describe("Optional: filter by category"),
|
|
758
766
|
}),
|
|
759
767
|
handler: async (args) => {
|
|
@@ -916,9 +924,12 @@ export function createTools(deps) {
|
|
|
916
924
|
}),
|
|
917
925
|
defineTool("wiki_read", {
|
|
918
926
|
description: "Read a specific wiki page by path. Use after wiki_search to read full page content. " +
|
|
919
|
-
"Paths are relative to the wiki root (
|
|
927
|
+
"Paths are relative to the wiki root. Layout: entity categories (projects, people, orgs, tools, " +
|
|
928
|
+
"topics, areas) live at 'pages/<category>/<topic>/index.md' (the topic overview) plus optional " +
|
|
929
|
+
"'pages/<category>/<topic>/<facet>.md' sub-pages; flat categories at 'pages/<category>.md' " +
|
|
930
|
+
"(preferences, facts, routines, decisions); daily summaries at 'pages/conversations/YYYY-MM-DD.md'.",
|
|
920
931
|
parameters: z.object({
|
|
921
|
-
path: z.string().describe("Path to the wiki page (e.g. 'pages/people/brian.md', 'index.md')"),
|
|
932
|
+
path: z.string().describe("Path to the wiki page (e.g. 'pages/people/brian/index.md', 'pages/projects/chapterhouse/decisions.md', 'index.md')"),
|
|
922
933
|
}),
|
|
923
934
|
handler: async (args) => {
|
|
924
935
|
ensureWikiStructure();
|
|
@@ -932,9 +943,14 @@ export function createTools(deps) {
|
|
|
932
943
|
description: "Create or update a wiki page. You provide the full page content (markdown with optional " +
|
|
933
944
|
"YAML frontmatter). The page will be written to disk and the index updated. Use this for " +
|
|
934
945
|
"rich knowledge pages, entity pages, synthesis documents — anything more structured than " +
|
|
935
|
-
"a quick 'remember' call. After creating/updating a page, the index is automatically updated."
|
|
946
|
+
"a quick 'remember' call. After creating/updating a page, the index is automatically updated. " +
|
|
947
|
+
"PATH RULES: entity-category pages MUST be 'pages/<category>/<topic-slug>/<page>.md' where " +
|
|
948
|
+
"category is one of projects, people, orgs, tools, topics, areas, '<page>' is 'index' for the " +
|
|
949
|
+
"topic overview or a facet name (e.g. 'decisions', 'feature-ideas') — exactly one topic level, " +
|
|
950
|
+
"lowercase slugs only. Flat-category pages MUST be 'pages/<category>.md' (preferences, facts, " +
|
|
951
|
+
"routines, decisions). Bad paths are rejected with a suggested correction.",
|
|
936
952
|
parameters: z.object({
|
|
937
|
-
path: z.string().describe("Page path relative to wiki root (e.g. 'pages/projects/
|
|
953
|
+
path: z.string().describe("Page path relative to wiki root (e.g. 'pages/projects/chapterhouse/index.md', 'pages/projects/chapterhouse/decisions.md', 'pages/people/brian/index.md')"),
|
|
938
954
|
title: z.string().describe("Page title for the index"),
|
|
939
955
|
summary: z.string().describe("One-line summary for the index"),
|
|
940
956
|
section: z.string().optional().describe("Index section (default: 'Knowledge')"),
|
package/dist/daemon.js
CHANGED
|
@@ -11,6 +11,7 @@ import { checkForUpdate } from "./update.js";
|
|
|
11
11
|
import { ensureWikiStructure } from "./wiki/fs.js";
|
|
12
12
|
import { seedTeamWiki } from "./wiki/seed-team-wiki.js";
|
|
13
13
|
import { shouldMigrate, migrateMemoriesToWiki, shouldReorganize, reorganizeWiki } from "./wiki/migrate.js";
|
|
14
|
+
import { shouldEnforceTopics, enforceTopicStructure } from "./wiki/migrate-topics.js";
|
|
14
15
|
import { SESSIONS_DIR } from "./paths.js";
|
|
15
16
|
import { getDisplayHost } from "./api/server-runtime.js";
|
|
16
17
|
import { StandupScheduler } from "./copilot/standup.js";
|
|
@@ -113,6 +114,11 @@ async function main() {
|
|
|
113
114
|
const count = reorganizeWiki();
|
|
114
115
|
log.info({ count }, "Created entity pages during reorganization");
|
|
115
116
|
}
|
|
117
|
+
if (shouldEnforceTopics()) {
|
|
118
|
+
log.info("Enforcing wiki topic-directory structure");
|
|
119
|
+
const moved = enforceTopicStructure();
|
|
120
|
+
log.info({ moved }, "Topic-structure migration complete");
|
|
121
|
+
}
|
|
116
122
|
// Prune orphaned session folders older than 7 days
|
|
117
123
|
pruneOldSessions();
|
|
118
124
|
// One-time deprecation note for legacy Telegram users (v1 → v2)
|
package/dist/wiki/fs.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlink
|
|
|
5
5
|
import { join, dirname, relative, resolve, sep } from "path";
|
|
6
6
|
import { WIKI_DIR, WIKI_PAGES_DIR, WIKI_SOURCES_DIR } from "../paths.js";
|
|
7
7
|
import { normalizeWikiPath } from "./path-utils.js";
|
|
8
|
+
import { topicPathError } from "./topic-structure.js";
|
|
8
9
|
const INDEX_PATH = join(WIKI_DIR, "index.md");
|
|
9
10
|
const LOG_PATH = join(WIKI_DIR, "log.md");
|
|
10
11
|
/**
|
|
@@ -43,6 +44,10 @@ export function assertPagePath(relativePath) {
|
|
|
43
44
|
if (!normalizedPath.endsWith(".md")) {
|
|
44
45
|
throw new Error(`Wiki page paths must end in .md: ${relativePath}`);
|
|
45
46
|
}
|
|
47
|
+
const structureError = topicPathError(normalizedPath);
|
|
48
|
+
if (structureError) {
|
|
49
|
+
throw new Error(`Wiki path violates the topic structure: ${structureError}`);
|
|
50
|
+
}
|
|
46
51
|
// resolvePath also enforces the wiki-root containment check.
|
|
47
52
|
resolvePath(normalizedPath);
|
|
48
53
|
}
|
|
@@ -6,6 +6,7 @@ import { join } from "path";
|
|
|
6
6
|
import { WIKI_DIR } from "../paths.js";
|
|
7
7
|
import { readIndexFile, writeIndexFile, listPages, readPage } from "./fs.js";
|
|
8
8
|
import { normalizeWikiPath } from "./path-utils.js";
|
|
9
|
+
import { entityCategories, FLAT_CATEGORIES } from "./topic-structure.js";
|
|
9
10
|
const INDEX_PATH = join(WIKI_DIR, "index.md");
|
|
10
11
|
// mtime-based cache so per-message context injection doesn't re-parse on every turn.
|
|
11
12
|
let cache;
|
|
@@ -39,8 +40,9 @@ export function parseIndex() {
|
|
|
39
40
|
currentSection = sectionMatch[1].trim();
|
|
40
41
|
continue;
|
|
41
42
|
}
|
|
42
|
-
// Entry lines
|
|
43
|
-
|
|
43
|
+
// Entry lines (possibly indented sub-bullets):
|
|
44
|
+
// - [Title](path) — Summary | tags: t1, t2 | updated: YYYY-MM-DD
|
|
45
|
+
const entryMatch = line.match(/^\s*-\s+\[(.+?)\]\((.+?)\)\s*[—–-]\s*(.+)/);
|
|
44
46
|
if (entryMatch) {
|
|
45
47
|
const rawSummary = entryMatch[3].trim();
|
|
46
48
|
// Parse optional | tags: ... | updated: ... suffixes
|
|
@@ -120,9 +122,11 @@ export function buildIndexEntryForPage(path, fallback) {
|
|
|
120
122
|
const body = content.replace(/^---[\s\S]*?---\s*/, "");
|
|
121
123
|
for (const raw of body.split("\n")) {
|
|
122
124
|
const line = raw.trim();
|
|
123
|
-
if (!line || line.startsWith("#"))
|
|
125
|
+
if (!line || line.startsWith("#") || line.startsWith("<!--"))
|
|
124
126
|
continue;
|
|
125
127
|
summary = line.replace(/^[-*]\s+/, "").replace(/_\(\d{4}-\d{2}-\d{2}\)_$/, "").trim();
|
|
128
|
+
if (!summary)
|
|
129
|
+
continue;
|
|
126
130
|
break;
|
|
127
131
|
}
|
|
128
132
|
}
|
|
@@ -157,7 +161,7 @@ export function rebuildIndexFromPages() {
|
|
|
157
161
|
section = sm[1].trim();
|
|
158
162
|
continue;
|
|
159
163
|
}
|
|
160
|
-
const em = line.match(
|
|
164
|
+
const em = line.match(/^\s*-\s+\[.+?\]\((.+?)\)/);
|
|
161
165
|
if (em) {
|
|
162
166
|
const normalizedPath = normalizeWikiPath(em[1].trim());
|
|
163
167
|
previous.set(normalizedPath, { path: normalizedPath, title: "", summary: "", section });
|
|
@@ -181,13 +185,51 @@ export function writeIndex(entries) {
|
|
|
181
185
|
writeIndexInternal(entries);
|
|
182
186
|
invalidateCache();
|
|
183
187
|
}
|
|
188
|
+
/** Derive the top-level category of a page from its path under pages/. */
|
|
189
|
+
function categoryOfPath(path) {
|
|
190
|
+
const rest = path.startsWith("pages/") ? path.slice("pages/".length) : path;
|
|
191
|
+
const segs = rest.split("/").filter(Boolean);
|
|
192
|
+
if (segs.length <= 1)
|
|
193
|
+
return (segs[0] || "pages").replace(/\.md$/i, "");
|
|
194
|
+
return segs[0];
|
|
195
|
+
}
|
|
196
|
+
/** Derive the topic slug of an entity-category page (pages/<cat>/<topic>/<file>), if any. */
|
|
197
|
+
function topicOfPath(path) {
|
|
198
|
+
const rest = path.startsWith("pages/") ? path.slice("pages/".length) : path;
|
|
199
|
+
const segs = rest.split("/").filter(Boolean);
|
|
200
|
+
return segs.length >= 3 ? segs[1] : undefined;
|
|
201
|
+
}
|
|
202
|
+
function isTopicIndexFile(path) {
|
|
203
|
+
return /\/index\.md$/i.test(path);
|
|
204
|
+
}
|
|
205
|
+
function humanize(slug) {
|
|
206
|
+
return slug.split(/[-_]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
207
|
+
}
|
|
208
|
+
function renderEntryLine(item, indent = "") {
|
|
209
|
+
let line = `${indent}- [${item.title}](${item.path}) — ${item.summary}`;
|
|
210
|
+
if (item.tags?.length)
|
|
211
|
+
line += ` | tags: ${item.tags.join(", ")}`;
|
|
212
|
+
if (item.updated)
|
|
213
|
+
line += ` | updated: ${item.updated}`;
|
|
214
|
+
return line;
|
|
215
|
+
}
|
|
184
216
|
function writeIndexInternal(entries) {
|
|
185
|
-
|
|
217
|
+
// Group by top-level category derived from the page path (not the stored
|
|
218
|
+
// `section`, which is no longer authoritative).
|
|
219
|
+
const byCategory = new Map();
|
|
186
220
|
for (const entry of entries) {
|
|
187
|
-
const
|
|
221
|
+
const cat = categoryOfPath(entry.path);
|
|
222
|
+
const list = byCategory.get(cat) || [];
|
|
188
223
|
list.push(entry);
|
|
189
|
-
|
|
224
|
+
byCategory.set(cat, list);
|
|
190
225
|
}
|
|
226
|
+
const entityCats = entityCategories();
|
|
227
|
+
const entityCatSet = new Set(entityCats);
|
|
228
|
+
const known = [...entityCats, ...FLAT_CATEGORIES];
|
|
229
|
+
const orderedCategories = [
|
|
230
|
+
...known.filter((c) => byCategory.has(c)),
|
|
231
|
+
...[...byCategory.keys()].filter((c) => !known.includes(c)).sort(),
|
|
232
|
+
];
|
|
191
233
|
const lines = [
|
|
192
234
|
"# Wiki Index",
|
|
193
235
|
"",
|
|
@@ -196,19 +238,52 @@ function writeIndexInternal(entries) {
|
|
|
196
238
|
`Last updated: ${new Date().toISOString().slice(0, 10)}`,
|
|
197
239
|
"",
|
|
198
240
|
];
|
|
199
|
-
for (const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
241
|
+
for (const cat of orderedCategories) {
|
|
242
|
+
const items = byCategory.get(cat);
|
|
243
|
+
lines.push(`## ${humanize(cat)}`, "");
|
|
244
|
+
if (entityCatSet.has(cat)) {
|
|
245
|
+
// Two-level layout: topic directory -> overview (index.md) + facet pages.
|
|
246
|
+
// Pages not yet in canonical <category>/<topic>/<file> shape are listed as
|
|
247
|
+
// plain bullets (they'll be relocated by the topic-structure migration).
|
|
248
|
+
const byTopic = new Map();
|
|
249
|
+
const ungrouped = [];
|
|
250
|
+
for (const entry of items) {
|
|
251
|
+
const topic = topicOfPath(entry.path);
|
|
252
|
+
if (!topic) {
|
|
253
|
+
ungrouped.push(entry);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const list = byTopic.get(topic) || [];
|
|
257
|
+
list.push(entry);
|
|
258
|
+
byTopic.set(topic, list);
|
|
259
|
+
}
|
|
260
|
+
for (const topic of [...byTopic.keys()].sort()) {
|
|
261
|
+
const topicItems = byTopic.get(topic);
|
|
262
|
+
const overview = topicItems.find((e) => isTopicIndexFile(e.path));
|
|
263
|
+
const facets = topicItems
|
|
264
|
+
.filter((e) => !isTopicIndexFile(e.path))
|
|
265
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
266
|
+
if (overview) {
|
|
267
|
+
lines.push(renderEntryLine(overview));
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
lines.push(`- **${humanize(topic)}** _(no overview page)_`);
|
|
271
|
+
}
|
|
272
|
+
for (const facet of facets)
|
|
273
|
+
lines.push(renderEntryLine(facet, " "));
|
|
274
|
+
}
|
|
275
|
+
for (const entry of ungrouped.sort((a, b) => a.path.localeCompare(b.path))) {
|
|
276
|
+
lines.push(renderEntryLine(entry));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
for (const entry of [...items].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
281
|
+
lines.push(renderEntryLine(entry));
|
|
282
|
+
}
|
|
208
283
|
}
|
|
209
284
|
lines.push("");
|
|
210
285
|
}
|
|
211
|
-
if (
|
|
286
|
+
if (orderedCategories.length === 0) {
|
|
212
287
|
lines.push("## Pages", "", "_(No pages yet.)_", "");
|
|
213
288
|
}
|
|
214
289
|
writeIndexFile(lines.join("\n"));
|
|
@@ -73,6 +73,25 @@ test("parseIndex self-heals an empty index from on-disk pages", async () => {
|
|
|
73
73
|
]);
|
|
74
74
|
assert.match(wikiFs.readIndexFile(), /\[Vision\]\(pages\/team\/vision\.md\)/);
|
|
75
75
|
});
|
|
76
|
+
test("the index renders entity categories as topic groups with nested facet pages", async () => {
|
|
77
|
+
const { indexManager, wikiFs } = await loadModules();
|
|
78
|
+
wikiFs.writePage("pages/projects/chapterhouse/index.md", "---\ntitle: Chapterhouse\nupdated: 2026-05-09\n---\n\n# Chapterhouse\n\nThe per-session orchestrator.\n");
|
|
79
|
+
wikiFs.writePage("pages/projects/chapterhouse/decisions.md", "---\ntitle: Chapterhouse Decisions\nupdated: 2026-05-09\n---\n\n# Decisions\n\nUse SSE for streaming.\n");
|
|
80
|
+
wikiFs.writePage("pages/preferences.md", "---\ntitle: Preferences\n---\n\n# Preferences\n\nDark mode.\n");
|
|
81
|
+
indexManager.rebuildIndexFromPages();
|
|
82
|
+
const index = wikiFs.readIndexFile();
|
|
83
|
+
assert.match(index, /## Projects/);
|
|
84
|
+
assert.match(index, /^- \[Chapterhouse\]\(pages\/projects\/chapterhouse\/index\.md\) — /m);
|
|
85
|
+
assert.match(index, /^ {2}- \[Chapterhouse Decisions\]\(pages\/projects\/chapterhouse\/decisions\.md\) — /m);
|
|
86
|
+
assert.match(index, /## Preferences/);
|
|
87
|
+
// Indented facet bullets must still round-trip through parseIndex.
|
|
88
|
+
const paths = indexManager.parseIndex().map((entry) => entry.path).sort();
|
|
89
|
+
assert.deepEqual(paths, [
|
|
90
|
+
"pages/preferences.md",
|
|
91
|
+
"pages/projects/chapterhouse/decisions.md",
|
|
92
|
+
"pages/projects/chapterhouse/index.md",
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
76
95
|
test("searchIndex ranks strong metadata matches and falls back to page bodies", async () => {
|
|
77
96
|
const { indexManager, wikiFs } = await loadModules();
|
|
78
97
|
wikiFs.writePage("pages/team/api.md", "# API\n\nObservability budget and telemetry plans.\n");
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// One-time migration: enforce the topic-directory structure for entity
|
|
3
|
+
// categories (pages/<category>/<topic-slug>/index.md + facet pages).
|
|
4
|
+
//
|
|
5
|
+
// Fixes wikis created before the structure was enforced, e.g.:
|
|
6
|
+
// pages/projects/chapterhouse.md -> pages/projects/chapterhouse/index.md
|
|
7
|
+
// pages/projects/chapterhouse-feature-ideas.md -> pages/projects/chapterhouse/feature-ideas.md
|
|
8
|
+
// pages/projects/chapterhouse/decisions.md -> (already correct, left alone)
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { getState, setState } from "../store/db.js";
|
|
13
|
+
import { WIKI_PAGES_DIR } from "../paths.js";
|
|
14
|
+
import { ensureWikiStructure, readPage, writePage, deletePage, listPages } from "./fs.js";
|
|
15
|
+
import { rebuildIndexFromPages } from "./index-manager.js";
|
|
16
|
+
import { appendLog } from "./log-manager.js";
|
|
17
|
+
import { entityCategories, slugify } from "./topic-structure.js";
|
|
18
|
+
import { childLogger } from "../util/logger.js";
|
|
19
|
+
const log = childLogger("wiki:migrate-topics");
|
|
20
|
+
const TOPIC_STRUCTURE_KEY = "wiki_topic_structure_v1";
|
|
21
|
+
export function shouldEnforceTopics() {
|
|
22
|
+
return getState(TOPIC_STRUCTURE_KEY) !== "true";
|
|
23
|
+
}
|
|
24
|
+
function dirNamesUnder(absDir) {
|
|
25
|
+
if (!existsSync(absDir))
|
|
26
|
+
return [];
|
|
27
|
+
return readdirSync(absDir, { withFileTypes: true })
|
|
28
|
+
.filter((e) => e.isDirectory())
|
|
29
|
+
.map((e) => e.name);
|
|
30
|
+
}
|
|
31
|
+
function fileBaseNamesUnder(absDir) {
|
|
32
|
+
if (!existsSync(absDir))
|
|
33
|
+
return [];
|
|
34
|
+
return readdirSync(absDir, { withFileTypes: true })
|
|
35
|
+
.filter((e) => e.isFile() && e.name.endsWith(".md"))
|
|
36
|
+
.map((e) => e.name.replace(/\.md$/i, ""));
|
|
37
|
+
}
|
|
38
|
+
function stripFrontmatter(content) {
|
|
39
|
+
return content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
|
|
40
|
+
}
|
|
41
|
+
/** Move src -> dest. If dest exists, merge src's body into it. Returns true if anything happened. */
|
|
42
|
+
function moveOrMerge(srcRel, destRel, today) {
|
|
43
|
+
if (srcRel === destRel)
|
|
44
|
+
return false;
|
|
45
|
+
const srcContent = readPage(srcRel);
|
|
46
|
+
if (srcContent == null)
|
|
47
|
+
return false;
|
|
48
|
+
const destContent = readPage(destRel);
|
|
49
|
+
if (destContent != null) {
|
|
50
|
+
const merged = `${destContent.trimEnd()}\n\n<!-- merged from ${srcRel} on ${today} -->\n\n${stripFrontmatter(srcContent).trim()}\n`;
|
|
51
|
+
writePage(destRel, merged);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
writePage(destRel, srcContent);
|
|
55
|
+
}
|
|
56
|
+
deletePage(srcRel);
|
|
57
|
+
appendLog("migrate", `topic-structure: ${srcRel} -> ${destRel}${destContent != null ? " (merged)" : ""}`);
|
|
58
|
+
log.info({ from: srcRel, to: destRel, merged: destContent != null }, "moved wiki page into topic structure");
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Bring the wiki page tree into compliance with the topic structure.
|
|
63
|
+
* Idempotent; safe to call repeatedly. Returns the number of pages moved/merged.
|
|
64
|
+
*/
|
|
65
|
+
export function enforceTopicStructure() {
|
|
66
|
+
ensureWikiStructure();
|
|
67
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
68
|
+
let moves = 0;
|
|
69
|
+
for (const cat of entityCategories()) {
|
|
70
|
+
const catAbs = join(WIKI_PAGES_DIR, cat);
|
|
71
|
+
if (!existsSync(catAbs) || !statSync(catAbs).isDirectory()) {
|
|
72
|
+
// Also handle a bare `pages/<cat>.md` flat file for an entity category.
|
|
73
|
+
const bareFlat = `pages/${cat}.md`;
|
|
74
|
+
if (readPage(bareFlat) != null) {
|
|
75
|
+
if (moveOrMerge(bareFlat, `pages/${cat}/general/index.md`, today)) {
|
|
76
|
+
moves++;
|
|
77
|
+
log.warn({ page: bareFlat }, "moved entity-category flat file to a 'general' topic; consider splitting it");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// Topics already represented by a directory under pages/<cat>/.
|
|
83
|
+
const existingDirs = new Set(dirNamesUnder(catAbs));
|
|
84
|
+
// All bare files directly under pages/<cat>/ (the things that need relocating).
|
|
85
|
+
const bareFiles = fileBaseNamesUnder(catAbs);
|
|
86
|
+
// The full set of candidate topic stems: existing dirs + every bare file stem.
|
|
87
|
+
const topicSet = new Set([...existingDirs, ...bareFiles]);
|
|
88
|
+
for (const base of bareFiles) {
|
|
89
|
+
const srcRel = `pages/${cat}/${base}.md`;
|
|
90
|
+
// Is `base` a facet of some other topic T (i.e. base === "T-<facet>")? Pick the longest such T.
|
|
91
|
+
let facetOf;
|
|
92
|
+
let facetName = "";
|
|
93
|
+
for (const t of topicSet) {
|
|
94
|
+
if (t === base)
|
|
95
|
+
continue;
|
|
96
|
+
if (base.startsWith(`${t}-`) && t.length > (facetOf?.length ?? 0)) {
|
|
97
|
+
facetOf = t;
|
|
98
|
+
facetName = base.slice(t.length + 1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (facetOf) {
|
|
102
|
+
const dest = `pages/${cat}/${slugify(facetOf)}/${slugify(facetName) || "notes"}.md`;
|
|
103
|
+
if (moveOrMerge(srcRel, dest, today))
|
|
104
|
+
moves++;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const dest = `pages/${cat}/${slugify(base)}/index.md`;
|
|
108
|
+
if (moveOrMerge(srcRel, dest, today))
|
|
109
|
+
moves++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (moves > 0) {
|
|
114
|
+
rebuildIndexFromPages();
|
|
115
|
+
appendLog("migrate", `topic-structure: ${moves} page(s) relocated; index rebuilt`);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
// Still rebuild once so the index picks up the hierarchical layout.
|
|
119
|
+
rebuildIndexFromPages();
|
|
120
|
+
}
|
|
121
|
+
// Sanity log of anything still off (non-blocking).
|
|
122
|
+
for (const p of listPages()) {
|
|
123
|
+
const segs = p.replace(/^pages\//, "").split("/").filter(Boolean);
|
|
124
|
+
if (entityCategories().includes(segs[0]) && segs.length !== 3) {
|
|
125
|
+
log.warn({ page: p }, "wiki page still not in canonical topic shape after migration");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
setState(TOPIC_STRUCTURE_KEY, "true");
|
|
129
|
+
log.info({ moves }, "topic-structure migration complete");
|
|
130
|
+
return moves;
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=migrate-topics.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
const repoRoot = process.cwd();
|
|
6
|
+
const sandboxRoot = join(repoRoot, ".test-work", `wiki-migrate-topics-${process.pid}`);
|
|
7
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
8
|
+
async function loadModules() {
|
|
9
|
+
const nonce = `${Date.now()}-${Math.random()}`;
|
|
10
|
+
const migrate = await import(new URL(`./migrate-topics.js?case=${nonce}`, import.meta.url).href);
|
|
11
|
+
const wikiFs = await import(new URL(`./fs.js?case=${nonce}`, import.meta.url).href);
|
|
12
|
+
return { migrate, wikiFs };
|
|
13
|
+
}
|
|
14
|
+
test.beforeEach(() => {
|
|
15
|
+
mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
|
|
16
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
test.after(() => {
|
|
19
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
test("enforceTopicStructure relocates bare entity pages and folds facet pages", async () => {
|
|
22
|
+
const { migrate, wikiFs } = await loadModules();
|
|
23
|
+
wikiFs.ensureWikiStructure();
|
|
24
|
+
wikiFs.writePage("pages/projects/chapterhouse.md", "---\ntitle: Chapterhouse\n---\n\n# Chapterhouse\n\n- Source at ~/projects/chapterhouse\n");
|
|
25
|
+
wikiFs.writePage("pages/projects/chapterhouse-feature-ideas.md", "---\ntitle: Chapterhouse Feature Ideas\n---\n\n# Feature Ideas\n\n- Add wiki topics\n");
|
|
26
|
+
wikiFs.writePage("pages/projects/chapterhouse/decisions.md", "---\ntitle: Decisions\n---\n\n# Decisions\n\n- Use SSE\n");
|
|
27
|
+
wikiFs.writePage("pages/conversations/2026-05-09.md", "# 2026-05-09\n\nDaily summary.\n");
|
|
28
|
+
const moves = migrate.enforceTopicStructure();
|
|
29
|
+
assert.equal(moves, 2);
|
|
30
|
+
assert.equal(wikiFs.pageExists("pages/projects/chapterhouse.md"), false);
|
|
31
|
+
assert.equal(wikiFs.pageExists("pages/projects/chapterhouse-feature-ideas.md"), false);
|
|
32
|
+
assert.equal(wikiFs.pageExists("pages/projects/chapterhouse/index.md"), true);
|
|
33
|
+
assert.equal(wikiFs.pageExists("pages/projects/chapterhouse/feature-ideas.md"), true);
|
|
34
|
+
assert.equal(wikiFs.pageExists("pages/projects/chapterhouse/decisions.md"), true);
|
|
35
|
+
// Conversations are exempt and untouched.
|
|
36
|
+
assert.equal(wikiFs.pageExists("pages/conversations/2026-05-09.md"), true);
|
|
37
|
+
assert.match(wikiFs.readPage("pages/projects/chapterhouse/index.md") ?? "", /Source at ~\/projects\/chapterhouse/);
|
|
38
|
+
assert.match(wikiFs.readPage("pages/projects/chapterhouse/feature-ideas.md") ?? "", /Add wiki topics/);
|
|
39
|
+
// Index reflects the new shape.
|
|
40
|
+
assert.match(wikiFs.readIndexFile(), /^- \[Chapterhouse\]\(pages\/projects\/chapterhouse\/index\.md\) — /m);
|
|
41
|
+
assert.match(wikiFs.readIndexFile(), /^ {2}- \[.+\]\(pages\/projects\/chapterhouse\/feature-ideas\.md\) — /m);
|
|
42
|
+
// Idempotent.
|
|
43
|
+
assert.equal(migrate.enforceTopicStructure(), 0);
|
|
44
|
+
});
|
|
45
|
+
test("enforceTopicStructure merges into an existing topic index instead of clobbering it", async () => {
|
|
46
|
+
const { migrate, wikiFs } = await loadModules();
|
|
47
|
+
wikiFs.ensureWikiStructure();
|
|
48
|
+
wikiFs.writePage("pages/people/brian/index.md", "---\ntitle: Brian\n---\n\n# Brian\n\n- Likes Go\n");
|
|
49
|
+
wikiFs.writePage("pages/people/brian.md", "---\ntitle: Brian\n---\n\n# Brian\n\n- Based in the US\n");
|
|
50
|
+
const moves = migrate.enforceTopicStructure();
|
|
51
|
+
assert.equal(moves, 1);
|
|
52
|
+
assert.equal(wikiFs.pageExists("pages/people/brian.md"), false);
|
|
53
|
+
const merged = wikiFs.readPage("pages/people/brian/index.md") ?? "";
|
|
54
|
+
assert.match(merged, /Likes Go/);
|
|
55
|
+
assert.match(merged, /Based in the US/);
|
|
56
|
+
});
|
|
57
|
+
//# sourceMappingURL=migrate-topics.test.js.map
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Wiki topic structure: the canonical layout rules for pages/.
|
|
3
|
+
//
|
|
4
|
+
// Entity categories (projects, people, orgs, tools, topics, areas by default)
|
|
5
|
+
// require a topic-level directory: pages/<category>/<topic-slug>/<page>.md,
|
|
6
|
+
// where index.md is the topic's overview page and other files are facets.
|
|
7
|
+
//
|
|
8
|
+
// Flat categories (preferences, facts, routines, decisions) stay as single
|
|
9
|
+
// files: pages/<category>.md.
|
|
10
|
+
//
|
|
11
|
+
// Some prefixes (conversations/, team/, okrs/, kpis/, shared/) follow their
|
|
12
|
+
// own conventions and are exempt from these rules.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
import { config } from "../config.js";
|
|
15
|
+
import { normalizeWikiPath } from "./path-utils.js";
|
|
16
|
+
/**
|
|
17
|
+
* Flat categories: each is a single file at pages/<category>.md.
|
|
18
|
+
* Note: `decisions` is intentionally NOT flat — a decision is always recorded as a
|
|
19
|
+
* `decisions.md` facet under the entity it concerns (e.g. pages/projects/chapterhouse/decisions.md).
|
|
20
|
+
*/
|
|
21
|
+
export const FLAT_CATEGORIES = ["preferences", "facts", "routines"];
|
|
22
|
+
/** Path prefixes (relative to the wiki root) that follow their own conventions. */
|
|
23
|
+
export const EXEMPT_PREFIXES = [
|
|
24
|
+
"pages/conversations/",
|
|
25
|
+
"pages/team/",
|
|
26
|
+
"pages/okrs/",
|
|
27
|
+
"pages/kpis/",
|
|
28
|
+
"pages/shared/",
|
|
29
|
+
];
|
|
30
|
+
/** Map a `remember`-style category name to its directory under pages/. */
|
|
31
|
+
const CATEGORY_DIR_MAP = {
|
|
32
|
+
person: "people",
|
|
33
|
+
people: "people",
|
|
34
|
+
project: "projects",
|
|
35
|
+
projects: "projects",
|
|
36
|
+
org: "orgs",
|
|
37
|
+
orgs: "orgs",
|
|
38
|
+
organization: "orgs",
|
|
39
|
+
tool: "tools",
|
|
40
|
+
tools: "tools",
|
|
41
|
+
topic: "topics",
|
|
42
|
+
topics: "topics",
|
|
43
|
+
area: "areas",
|
|
44
|
+
areas: "areas",
|
|
45
|
+
preference: "preferences",
|
|
46
|
+
preferences: "preferences",
|
|
47
|
+
fact: "facts",
|
|
48
|
+
facts: "facts",
|
|
49
|
+
routine: "routines",
|
|
50
|
+
routines: "routines",
|
|
51
|
+
decision: "decisions",
|
|
52
|
+
decisions: "decisions",
|
|
53
|
+
};
|
|
54
|
+
export function getCategoryDir(category) {
|
|
55
|
+
return CATEGORY_DIR_MAP[category.toLowerCase()] || category.toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
/** The active list of entity-category directories (configurable via WIKI_ENTITY_CATEGORIES). */
|
|
58
|
+
export function entityCategories() {
|
|
59
|
+
return config.wikiEntityCategories;
|
|
60
|
+
}
|
|
61
|
+
function isEntityCategory(dir) {
|
|
62
|
+
return entityCategories().includes(dir);
|
|
63
|
+
}
|
|
64
|
+
function isFlatCategory(dir) {
|
|
65
|
+
return FLAT_CATEGORIES.includes(dir);
|
|
66
|
+
}
|
|
67
|
+
function isExemptPath(relativePath) {
|
|
68
|
+
return EXEMPT_PREFIXES.some((p) => relativePath.startsWith(p));
|
|
69
|
+
}
|
|
70
|
+
/** Slugify a free-text name into a wiki-safe path segment. */
|
|
71
|
+
export function slugify(name) {
|
|
72
|
+
return name
|
|
73
|
+
.toLowerCase()
|
|
74
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
75
|
+
.replace(/^-+|-+$/g, "")
|
|
76
|
+
|| "untitled";
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Canonical path for an entity topic page.
|
|
80
|
+
* topicPagePath("project", "Chapterhouse") -> "pages/projects/chapterhouse/index.md"
|
|
81
|
+
* topicPagePath("project", "Chapterhouse", "decisions") -> "pages/projects/chapterhouse/decisions.md"
|
|
82
|
+
*/
|
|
83
|
+
export function topicPagePath(category, entity, facet = "index") {
|
|
84
|
+
const dir = getCategoryDir(category);
|
|
85
|
+
const topic = slugify(entity);
|
|
86
|
+
const page = slugify(facet) || "index";
|
|
87
|
+
return `pages/${dir}/${topic}/${page}.md`;
|
|
88
|
+
}
|
|
89
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
90
|
+
/**
|
|
91
|
+
* Validate that a tool-writable wiki page path conforms to the canonical layout.
|
|
92
|
+
* Assumes the path has already passed the basic safety checks in assertPagePath
|
|
93
|
+
* (under pages/, ends in .md, no traversal).
|
|
94
|
+
*/
|
|
95
|
+
export function validateTopicPath(relativePath) {
|
|
96
|
+
const path = normalizeWikiPath(relativePath);
|
|
97
|
+
if (!path.startsWith("pages/")) {
|
|
98
|
+
// assertPagePath handles this; nothing topic-structural to add.
|
|
99
|
+
return { ok: true };
|
|
100
|
+
}
|
|
101
|
+
if (isExemptPath(path)) {
|
|
102
|
+
return { ok: true };
|
|
103
|
+
}
|
|
104
|
+
const rest = path.slice("pages/".length); // e.g. "projects/chapterhouse/index.md"
|
|
105
|
+
const segments = rest.split("/").filter(Boolean);
|
|
106
|
+
// For a single-segment path the "category" is the bare filename (pages/preferences.md).
|
|
107
|
+
const topDir = segments.length === 1 ? segments[0].replace(/\.md$/i, "") : (segments[0] ?? "");
|
|
108
|
+
if (isFlatCategory(topDir)) {
|
|
109
|
+
if (segments.length === 1 && rest === `${topDir}.md`) {
|
|
110
|
+
return { ok: true };
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
reason: `'${topDir}' is a flat category and must be a single file.`,
|
|
115
|
+
suggestion: `pages/${topDir}.md`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (isEntityCategory(topDir)) {
|
|
119
|
+
if (segments.length === 2) {
|
|
120
|
+
// pages/projects/foo.md -> pages/projects/foo/index.md
|
|
121
|
+
const base = slugify(segments[1].replace(/\.md$/i, ""));
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
reason: `Entity category '${topDir}' requires a topic directory: pages/${topDir}/<topic>/<page>.md. ` +
|
|
125
|
+
`'index.md' is the topic's overview page.`,
|
|
126
|
+
suggestion: `pages/${topDir}/${base}/index.md`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (segments.length !== 3) {
|
|
130
|
+
const flat = segments.slice(1, -1).map((s) => slugify(s.replace(/\.md$/i, ""))).filter(Boolean);
|
|
131
|
+
const topic = flat[0] || "topic";
|
|
132
|
+
const page = slugify(segments[segments.length - 1].replace(/\.md$/i, "")) || "index";
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
reason: `Entity category '${topDir}' allows exactly one topic level: pages/${topDir}/<topic>/<page>.md. ` +
|
|
136
|
+
`Got ${segments.length} path segments.`,
|
|
137
|
+
suggestion: `pages/${topDir}/${topic}/${page}.md`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const [, topic, file] = segments;
|
|
141
|
+
const pageSlug = file.replace(/\.md$/i, "");
|
|
142
|
+
if (!SLUG_RE.test(topic) || !SLUG_RE.test(pageSlug)) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
reason: `Topic and page names must be lowercase slugs ([a-z0-9-]).`,
|
|
146
|
+
suggestion: `pages/${topDir}/${slugify(topic)}/${slugify(pageSlug) || "index"}.md`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return { ok: true };
|
|
150
|
+
}
|
|
151
|
+
// Unknown top-level directory.
|
|
152
|
+
const valid = [...entityCategories(), ...FLAT_CATEGORIES].sort().join(", ");
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
reason: `'${topDir}' is not a recognized wiki category. ` +
|
|
156
|
+
`Use one of: ${valid} (entity categories take a topic directory: pages/<category>/<topic>/<page>.md). ` +
|
|
157
|
+
`Conversations and team pages are written by the system.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** Convenience: returns the error message string for an invalid path, or undefined if valid. */
|
|
161
|
+
export function topicPathError(relativePath) {
|
|
162
|
+
const result = validateTopicPath(relativePath);
|
|
163
|
+
if (result.ok)
|
|
164
|
+
return undefined;
|
|
165
|
+
return result.suggestion ? `${result.reason} Use: ${result.suggestion}` : result.reason;
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=topic-structure.js.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { getCategoryDir, slugify, topicPagePath, validateTopicPath, topicPathError, } from "./topic-structure.js";
|
|
4
|
+
test("getCategoryDir maps category names to directories", () => {
|
|
5
|
+
assert.equal(getCategoryDir("project"), "projects");
|
|
6
|
+
assert.equal(getCategoryDir("projects"), "projects");
|
|
7
|
+
assert.equal(getCategoryDir("person"), "people");
|
|
8
|
+
assert.equal(getCategoryDir("org"), "orgs");
|
|
9
|
+
assert.equal(getCategoryDir("tool"), "tools");
|
|
10
|
+
assert.equal(getCategoryDir("topic"), "topics");
|
|
11
|
+
assert.equal(getCategoryDir("area"), "areas");
|
|
12
|
+
assert.equal(getCategoryDir("decision"), "decisions");
|
|
13
|
+
assert.equal(getCategoryDir("preference"), "preferences");
|
|
14
|
+
});
|
|
15
|
+
test("slugify normalizes free text into a safe path segment", () => {
|
|
16
|
+
assert.equal(slugify("Chapter House!"), "chapter-house");
|
|
17
|
+
assert.equal(slugify(" --Foo_Bar-- "), "foo-bar");
|
|
18
|
+
assert.equal(slugify("v0.3.14"), "v0-3-14");
|
|
19
|
+
assert.equal(slugify("!!!"), "untitled");
|
|
20
|
+
});
|
|
21
|
+
test("topicPagePath builds canonical paths", () => {
|
|
22
|
+
assert.equal(topicPagePath("project", "Chapterhouse"), "pages/projects/chapterhouse/index.md");
|
|
23
|
+
assert.equal(topicPagePath("project", "Chapterhouse", "decisions"), "pages/projects/chapterhouse/decisions.md");
|
|
24
|
+
assert.equal(topicPagePath("person", "Brian K"), "pages/people/brian-k/index.md");
|
|
25
|
+
assert.equal(topicPagePath("tool", "Kubernetes"), "pages/tools/kubernetes/index.md");
|
|
26
|
+
});
|
|
27
|
+
test("validateTopicPath accepts canonical and exempt paths", () => {
|
|
28
|
+
assert.deepEqual(validateTopicPath("pages/projects/chapterhouse/index.md"), { ok: true });
|
|
29
|
+
assert.deepEqual(validateTopicPath("pages/projects/chapterhouse/decisions.md"), { ok: true });
|
|
30
|
+
assert.deepEqual(validateTopicPath("pages/people/brian/index.md"), { ok: true });
|
|
31
|
+
assert.deepEqual(validateTopicPath("pages/preferences.md"), { ok: true });
|
|
32
|
+
assert.deepEqual(validateTopicPath("pages/facts.md"), { ok: true });
|
|
33
|
+
assert.deepEqual(validateTopicPath("pages/conversations/2026-05-12.md"), { ok: true });
|
|
34
|
+
assert.deepEqual(validateTopicPath("pages/team/onboarding.md"), { ok: true });
|
|
35
|
+
assert.deepEqual(validateTopicPath("pages/okrs/2026-Q2.md"), { ok: true });
|
|
36
|
+
});
|
|
37
|
+
test("validateTopicPath rejects a bare entity-category file and suggests the topic index", () => {
|
|
38
|
+
const r = validateTopicPath("pages/projects/chapterhouse.md");
|
|
39
|
+
assert.equal(r.ok, false);
|
|
40
|
+
assert.equal(r.ok === false && r.suggestion, "pages/projects/chapterhouse/index.md");
|
|
41
|
+
});
|
|
42
|
+
test("validateTopicPath rejects entity paths that nest too deep", () => {
|
|
43
|
+
const r = validateTopicPath("pages/projects/chapterhouse/sub/deep.md");
|
|
44
|
+
assert.equal(r.ok, false);
|
|
45
|
+
assert.equal(r.ok === false && r.suggestion, "pages/projects/chapterhouse/deep.md");
|
|
46
|
+
});
|
|
47
|
+
test("validateTopicPath rejects a flat category used as a directory", () => {
|
|
48
|
+
const r = validateTopicPath("pages/preferences/foo.md");
|
|
49
|
+
assert.equal(r.ok, false);
|
|
50
|
+
assert.equal(r.ok === false && r.suggestion, "pages/preferences.md");
|
|
51
|
+
});
|
|
52
|
+
test("validateTopicPath treats `decisions` as an entity facet, not a flat page", () => {
|
|
53
|
+
// A standalone pages/decisions.md is no longer valid…
|
|
54
|
+
assert.equal(validateTopicPath("pages/decisions.md").ok, false);
|
|
55
|
+
// …decisions live as a facet under the entity they concern.
|
|
56
|
+
assert.deepEqual(validateTopicPath("pages/projects/chapterhouse/decisions.md"), { ok: true });
|
|
57
|
+
assert.deepEqual(validateTopicPath("pages/tools/kubernetes/decisions.md"), { ok: true });
|
|
58
|
+
});
|
|
59
|
+
test("validateTopicPath rejects unknown top-level categories", () => {
|
|
60
|
+
const r = validateTopicPath("pages/randomstuff/foo.md");
|
|
61
|
+
assert.equal(r.ok, false);
|
|
62
|
+
assert.match(r.ok === false ? r.reason : "", /not a recognized wiki category/);
|
|
63
|
+
});
|
|
64
|
+
test("validateTopicPath rejects non-slug topic or page names", () => {
|
|
65
|
+
const r = validateTopicPath("pages/projects/Chapter House/index.md");
|
|
66
|
+
assert.equal(r.ok, false);
|
|
67
|
+
assert.equal(r.ok === false && r.suggestion, "pages/projects/chapter-house/index.md");
|
|
68
|
+
});
|
|
69
|
+
test("topicPathError returns a message for invalid paths and undefined for valid ones", () => {
|
|
70
|
+
assert.equal(topicPathError("pages/projects/chapterhouse/index.md"), undefined);
|
|
71
|
+
assert.equal(topicPathError("pages/preferences.md"), undefined);
|
|
72
|
+
assert.match(String(topicPathError("pages/projects/chapterhouse.md")), /Use: pages\/projects\/chapterhouse\/index\.md/);
|
|
73
|
+
});
|
|
74
|
+
//# sourceMappingURL=topic-structure.test.js.map
|
package/package.json
CHANGED