chapterhouse 0.3.14 → 0.3.16
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 +12 -1
- package/dist/copilot/system-message.js +7 -0
- package/dist/copilot/tools.js +81 -76
- package/dist/copilot/tools.wiki.test.js +143 -0
- package/dist/daemon.js +6 -0
- package/dist/wiki/frontmatter.js +148 -0
- package/dist/wiki/frontmatter.test.js +109 -0
- package/dist/wiki/fs.js +8 -3
- package/dist/wiki/fs.test.js +3 -2
- package/dist/wiki/index-manager.js +106 -49
- package/dist/wiki/index-manager.test.js +22 -3
- package/dist/wiki/lint.js +424 -0
- package/dist/wiki/lint.test.js +260 -0
- package/dist/wiki/log-manager.js +52 -9
- package/dist/wiki/log-manager.test.js +47 -0
- package/dist/wiki/migrate-topics.js +132 -0
- package/dist/wiki/migrate-topics.test.js +57 -0
- package/dist/wiki/taxonomy.js +73 -0
- package/dist/wiki/taxonomy.test.js +70 -0
- package/dist/wiki/topic-structure.js +167 -0
- package/dist/wiki/topic-structure.test.js +74 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-BlIWCM11.js → index-BYuMgJ36.js} +61 -61
- package/web/dist/assets/index-BYuMgJ36.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BlIWCM11.js.map +0 -1
package/dist/wiki/log-manager.js
CHANGED
|
@@ -1,20 +1,63 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// Wiki log
|
|
2
|
+
// Wiki action-log manager — append-only chronological operation log
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
|
-
import { appendFileSync } from "fs";
|
|
4
|
+
import { appendFileSync, existsSync, readFileSync, renameSync } from "fs";
|
|
5
5
|
import { join } from "path";
|
|
6
|
-
import {
|
|
7
|
-
import { ensureWikiStructure } from "./fs.js";
|
|
8
|
-
const
|
|
6
|
+
import { WIKI_PAGES_DIR } from "../paths.js";
|
|
7
|
+
import { ensureWikiStructure, writeFileAtomic } from "./fs.js";
|
|
8
|
+
export const ACTION_LOG_PATH = "pages/_meta/log.md";
|
|
9
|
+
const LOG_PATH = join(WIKI_PAGES_DIR, "_meta", "log.md");
|
|
10
|
+
const MAX_LOG_ENTRIES = 500;
|
|
11
|
+
const LOG_ENTRY_RE = /^## \[/gm;
|
|
12
|
+
const INITIAL_LOG = `# Wiki Action Log
|
|
13
|
+
|
|
14
|
+
_Append-only record of wiki operations._
|
|
15
|
+
|
|
16
|
+
`;
|
|
9
17
|
/**
|
|
10
|
-
* Append a timestamped entry to log.md.
|
|
11
|
-
* Format: `## [YYYY-MM-DD HH:MM]
|
|
18
|
+
* Append a timestamped entry to pages/_meta/log.md.
|
|
19
|
+
* Format: `## [YYYY-MM-DD HH:MM] action | subject | agent`
|
|
12
20
|
*/
|
|
13
|
-
export function appendLog(type,
|
|
21
|
+
export function appendLog(type, subject, agent = resolveAgentName()) {
|
|
14
22
|
ensureWikiStructure();
|
|
23
|
+
rotateLogIfNeeded();
|
|
15
24
|
const now = new Date();
|
|
16
25
|
const ts = now.toISOString().slice(0, 16).replace("T", " ");
|
|
17
|
-
const entry = `## [${ts}] ${type} | ${
|
|
26
|
+
const entry = `## [${ts}] ${type} | ${subject} | ${agent}\n\n`;
|
|
18
27
|
appendFileSync(LOG_PATH, entry, "utf-8");
|
|
19
28
|
}
|
|
29
|
+
function rotateLogIfNeeded() {
|
|
30
|
+
const currentLog = readFileSync(LOG_PATH, "utf-8");
|
|
31
|
+
const entryCount = (currentLog.match(LOG_ENTRY_RE) ?? []).length;
|
|
32
|
+
if (entryCount < MAX_LOG_ENTRIES) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const archivePath = join(WIKI_PAGES_DIR, "_meta", `log-${new Date().getFullYear()}.md`);
|
|
36
|
+
if (!existsSync(archivePath)) {
|
|
37
|
+
renameSync(LOG_PATH, archivePath);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const archivedEntries = extractEntries(currentLog);
|
|
41
|
+
if (archivedEntries.length > 0) {
|
|
42
|
+
const separator = readFileSync(archivePath, "utf-8").endsWith("\n\n") ? "" : "\n\n";
|
|
43
|
+
appendFileSync(archivePath, `${separator}${archivedEntries.join("\n\n")}\n\n`, "utf-8");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
writeFileAtomic(LOG_PATH, INITIAL_LOG);
|
|
47
|
+
}
|
|
48
|
+
function extractEntries(content) {
|
|
49
|
+
return content
|
|
50
|
+
.split(/\n{2,}/)
|
|
51
|
+
.map((chunk) => chunk.trim())
|
|
52
|
+
.filter((chunk) => chunk.startsWith("## ["));
|
|
53
|
+
}
|
|
54
|
+
function resolveAgentName() {
|
|
55
|
+
const candidates = [
|
|
56
|
+
process.env.CHAPTERHOUSE_SESSION_AGENT_NAME,
|
|
57
|
+
process.env.CHAPTERHOUSE_AGENT_NAME,
|
|
58
|
+
process.env.COPILOT_AGENT_NAME,
|
|
59
|
+
process.env.AGENT_NAME,
|
|
60
|
+
];
|
|
61
|
+
return candidates.find((candidate) => candidate && candidate.trim().length > 0)?.trim() ?? "unknown";
|
|
62
|
+
}
|
|
20
63
|
//# sourceMappingURL=log-manager.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, 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-log-${process.pid}`);
|
|
7
|
+
const wikiDir = join(sandboxRoot, ".chapterhouse", "wiki");
|
|
8
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
9
|
+
process.env.CHAPTERHOUSE_AGENT_NAME = "wiki-test-agent";
|
|
10
|
+
async function loadModules() {
|
|
11
|
+
const nonce = `${Date.now()}-${Math.random()}`;
|
|
12
|
+
const logManager = await import(new URL(`./log-manager.js?case=${nonce}`, import.meta.url).href);
|
|
13
|
+
const wikiFs = await import(new URL(`./fs.js?case=${nonce}`, import.meta.url).href);
|
|
14
|
+
return { logManager, wikiFs };
|
|
15
|
+
}
|
|
16
|
+
function resetSandbox() {
|
|
17
|
+
mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
|
|
18
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
test.beforeEach(() => {
|
|
21
|
+
resetSandbox();
|
|
22
|
+
});
|
|
23
|
+
test.after(() => {
|
|
24
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
test("appendLog writes action entries to pages/_meta/log.md with the resolved agent name", async () => {
|
|
27
|
+
const { logManager, wikiFs } = await loadModules();
|
|
28
|
+
wikiFs.ensureWikiStructure();
|
|
29
|
+
logManager.appendLog("update", "wiki_update: Chapterhouse (pages/shared/chapterhouse.md)");
|
|
30
|
+
assert.equal(existsSync(join(wikiDir, "pages", "_meta", "log.md")), true);
|
|
31
|
+
assert.match(wikiFs.readLogFile(), /^## \[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\] update \| wiki_update: Chapterhouse \(pages\/shared\/chapterhouse\.md\) \| wiki-test-agent/m);
|
|
32
|
+
});
|
|
33
|
+
test("appendLog rotates the active log after 500 entries and keeps appending to a fresh log", async () => {
|
|
34
|
+
const { logManager, wikiFs } = await loadModules();
|
|
35
|
+
wikiFs.ensureWikiStructure();
|
|
36
|
+
wikiFs.writeLogFile("# Wiki Action Log\n\n" +
|
|
37
|
+
Array.from({ length: 500 }, (_, index) => `## [2026-01-01 00:00] update | historical entry ${index + 1} | wiki-test-agent\n\n`).join(""));
|
|
38
|
+
logManager.appendLog("update", "fresh entry after rotation");
|
|
39
|
+
const archiveYear = String(new Date().getFullYear());
|
|
40
|
+
const activeLog = wikiFs.readLogFile();
|
|
41
|
+
const archivedLog = wikiFs.readPage(`pages/_meta/log-${archiveYear}.md`);
|
|
42
|
+
assert.ok(archivedLog);
|
|
43
|
+
assert.match(archivedLog, /historical entry 1/);
|
|
44
|
+
assert.doesNotMatch(activeLog, /historical entry 1/);
|
|
45
|
+
assert.match(activeLog, /fresh entry after rotation/);
|
|
46
|
+
});
|
|
47
|
+
//# sourceMappingURL=log-manager.test.js.map
|
|
@@ -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,73 @@
|
|
|
1
|
+
import { readPage } from "./fs.js";
|
|
2
|
+
const ENGINEERING_TAGS = [
|
|
3
|
+
"engineering",
|
|
4
|
+
"architecture",
|
|
5
|
+
"release",
|
|
6
|
+
"runbook",
|
|
7
|
+
"incident",
|
|
8
|
+
"orchestration",
|
|
9
|
+
];
|
|
10
|
+
const PEOPLE_TAGS = [
|
|
11
|
+
"people",
|
|
12
|
+
"person",
|
|
13
|
+
"org",
|
|
14
|
+
"team",
|
|
15
|
+
"tool",
|
|
16
|
+
];
|
|
17
|
+
const PROCESS_TAGS = [
|
|
18
|
+
"process",
|
|
19
|
+
"project",
|
|
20
|
+
"topic",
|
|
21
|
+
"area",
|
|
22
|
+
"decision",
|
|
23
|
+
"preference",
|
|
24
|
+
"fact",
|
|
25
|
+
"routine",
|
|
26
|
+
"research",
|
|
27
|
+
];
|
|
28
|
+
const META_TAGS = [
|
|
29
|
+
"meta",
|
|
30
|
+
"index",
|
|
31
|
+
"source",
|
|
32
|
+
"taxonomy",
|
|
33
|
+
"autostub",
|
|
34
|
+
];
|
|
35
|
+
export const DEFAULT_TAGS = [
|
|
36
|
+
...ENGINEERING_TAGS,
|
|
37
|
+
...PEOPLE_TAGS,
|
|
38
|
+
...PROCESS_TAGS,
|
|
39
|
+
...META_TAGS,
|
|
40
|
+
];
|
|
41
|
+
export const TAXONOMY_PATH = "pages/_meta/taxonomy.md";
|
|
42
|
+
export function parseTaxonomyTags(content) {
|
|
43
|
+
const tags = new Set();
|
|
44
|
+
let seenHeading = false;
|
|
45
|
+
for (const rawLine of content.split("\n")) {
|
|
46
|
+
const line = rawLine.trim();
|
|
47
|
+
if (!line)
|
|
48
|
+
continue;
|
|
49
|
+
if (/^##\s+.+/.test(line)) {
|
|
50
|
+
seenHeading = true;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const bulletMatch = line.match(/^-\s+([a-z0-9][a-z0-9-]*)$/i);
|
|
54
|
+
if (bulletMatch && seenHeading) {
|
|
55
|
+
tags.add(bulletMatch[1].toLowerCase());
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`Invalid wiki taxonomy at ${TAXONOMY_PATH}: expected '## Group' headings with '- tag' bullets.`);
|
|
59
|
+
}
|
|
60
|
+
return [...tags];
|
|
61
|
+
}
|
|
62
|
+
export function loadTaxonomy() {
|
|
63
|
+
const tags = new Set(DEFAULT_TAGS);
|
|
64
|
+
const override = readPage(TAXONOMY_PATH);
|
|
65
|
+
if (!override) {
|
|
66
|
+
return [...tags].sort();
|
|
67
|
+
}
|
|
68
|
+
for (const tag of parseTaxonomyTags(override)) {
|
|
69
|
+
tags.add(tag);
|
|
70
|
+
}
|
|
71
|
+
return [...tags].sort();
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=taxonomy.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
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-taxonomy-${process.pid}`);
|
|
7
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
8
|
+
async function loadModules() {
|
|
9
|
+
const nonce = `${Date.now()}-${Math.random()}`;
|
|
10
|
+
const taxonomy = await import(new URL(`./taxonomy.js?case=${nonce}`, import.meta.url).href);
|
|
11
|
+
const wikiFs = await import(new URL(`./fs.js?case=${nonce}`, import.meta.url).href);
|
|
12
|
+
return { taxonomy, wikiFs };
|
|
13
|
+
}
|
|
14
|
+
function resetSandbox() {
|
|
15
|
+
mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
|
|
16
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
test.beforeEach(() => {
|
|
19
|
+
resetSandbox();
|
|
20
|
+
});
|
|
21
|
+
test.after(() => {
|
|
22
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
test("loadTaxonomy returns the default tags when no wiki taxonomy file exists", async () => {
|
|
25
|
+
const { taxonomy } = await loadModules();
|
|
26
|
+
const merged = taxonomy.loadTaxonomy();
|
|
27
|
+
assert.deepEqual(merged, [...taxonomy.DEFAULT_TAGS].sort());
|
|
28
|
+
});
|
|
29
|
+
test("loadTaxonomy merges pages/_meta/taxonomy.md entries with the defaults", async () => {
|
|
30
|
+
const { taxonomy, wikiFs } = await loadModules();
|
|
31
|
+
wikiFs.writePage("pages/_meta/taxonomy.md", `## Engineering
|
|
32
|
+
- orchestration
|
|
33
|
+
- release
|
|
34
|
+
|
|
35
|
+
## Custom Group
|
|
36
|
+
- teammate
|
|
37
|
+
`);
|
|
38
|
+
const merged = taxonomy.loadTaxonomy();
|
|
39
|
+
assert.equal(merged.includes("orchestration"), true);
|
|
40
|
+
assert.equal(merged.includes("release"), true);
|
|
41
|
+
assert.equal(merged.includes("teammate"), true);
|
|
42
|
+
assert.equal(merged.includes("engineering"), true);
|
|
43
|
+
});
|
|
44
|
+
test("loadTaxonomy deduplicates repeated tags across defaults and overrides", async () => {
|
|
45
|
+
const { taxonomy, wikiFs } = await loadModules();
|
|
46
|
+
wikiFs.writePage("pages/_meta/taxonomy.md", `## Engineering
|
|
47
|
+
- engineering
|
|
48
|
+
- release
|
|
49
|
+
- release
|
|
50
|
+
`);
|
|
51
|
+
const merged = taxonomy.loadTaxonomy();
|
|
52
|
+
assert.equal(merged.filter((tag) => tag === "engineering").length, 1);
|
|
53
|
+
assert.equal(merged.filter((tag) => tag === "release").length, 1);
|
|
54
|
+
});
|
|
55
|
+
test("loadTaxonomy accepts arbitrary heading labels as grouping metadata", async () => {
|
|
56
|
+
const { taxonomy, wikiFs } = await loadModules();
|
|
57
|
+
wikiFs.writePage("pages/_meta/taxonomy.md", `## Totally New Bucket
|
|
58
|
+
- field-note
|
|
59
|
+
`);
|
|
60
|
+
const merged = taxonomy.loadTaxonomy();
|
|
61
|
+
assert.equal(merged.includes("field-note"), true);
|
|
62
|
+
});
|
|
63
|
+
test("loadTaxonomy rejects malformed taxonomy markdown clearly", async () => {
|
|
64
|
+
const { taxonomy, wikiFs } = await loadModules();
|
|
65
|
+
wikiFs.writePage("pages/_meta/taxonomy.md", `## Engineering
|
|
66
|
+
not-a-bullet
|
|
67
|
+
`);
|
|
68
|
+
assert.throws(() => taxonomy.loadTaxonomy(), /Invalid wiki taxonomy/i);
|
|
69
|
+
});
|
|
70
|
+
//# sourceMappingURL=taxonomy.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
|