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 CHANGED
@@ -3,7 +3,7 @@ import { z } from "zod";
3
3
  import { existsSync, readFileSync, writeFileSync } from "fs";
4
4
  import { API_TOKEN_PATH, ENV_PATH, ensureChapterhouseHome } from "./paths.js";
5
5
  export const DISABLE_DOTENV_ENV_VAR = "CHAPTERHOUSE_DISABLE_DOTENV";
6
- const DEFAULT_WORKER_TIMEOUT_MS = 600_000;
6
+ const DEFAULT_WORKER_TIMEOUT_MS = 900_000;
7
7
  const BOOLEAN_ENV_PATTERN = /^(true|false)$/;
8
8
  function loadRuntimeEnv() {
9
9
  if (process.env[DISABLE_DOTENV_ENV_VAR] === "1") {
@@ -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.
@@ -8,9 +8,13 @@ import { listSkills, createSkill, removeSkill } from "./skills.js";
8
8
  import { config, persistModel } from "../config.js";
9
9
  import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, switchSessionModel, } from "./orchestrator.js";
10
10
  import { getRouterConfig, updateRouterConfig } from "./router.js";
11
- import { ensureWikiStructure, readPage, writePage, deletePage, listPages, writeRawSource, listSources, assertPagePath } from "../wiki/fs.js";
12
- import { searchIndex, addToIndex, removeFromIndex, parseIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
11
+ import { ensureWikiStructure, readPage, writePage, deletePage, writeRawSource, assertPagePath } from "../wiki/fs.js";
12
+ import { searchIndex, addToIndex, removeFromIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
13
+ import { validateWikiFrontmatter } from "../wiki/frontmatter.js";
14
+ import { lintWiki, renderWikiLintReport } from "../wiki/lint.js";
13
15
  import { appendLog } from "../wiki/log-manager.js";
16
+ import { loadTaxonomy } from "../wiki/taxonomy.js";
17
+ import { getCategoryDir, topicPagePath, slugify, entityCategories, FLAT_CATEGORIES } from "../wiki/topic-structure.js";
14
18
  import { withWikiWrite } from "../wiki/lock.js";
15
19
  import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
16
20
  import { getAgentRegistry, getAgent, createEphemeralAgentSession, getAgentSessionStatus, getTask, registerTask, completeTask, failTask, createAgentFile, removeAgentFile, loadAgents, } from "./agents.js";
@@ -20,17 +24,6 @@ import { TeamPushClient } from "../integrations/team-push.js";
20
24
  import { OKRMapper, parseOKRPageContent } from "./okr-mapper.js";
21
25
  import { childLogger } from "../util/logger.js";
22
26
  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
27
  /** Escape a string for safe inclusion as a single-line YAML scalar value. */
35
28
  function yamlEscape(value) {
36
29
  // Always quote and escape backslashes, double quotes, and newlines.
@@ -650,55 +643,73 @@ export function createTools(deps) {
650
643
  }),
651
644
  // ----- Wiki-backed memory facades (preserve existing remember/recall/forget UX) -----
652
645
  defineTool("remember", {
653
- description: "Save a fact, preference, or detail to the wiki. Routes to entity-specific pages automatically. " +
646
+ description: "Save a fact, preference, or detail to the wiki. Routes to topic pages automatically. " +
654
647
  "Use for discrete facts ('The team prefers dark mode', 'Project uses Vercel'). " +
648
+ "Entity categories (project/person/org/tool/topic/area) are filed under pages/<category>/<topic>/index.md; " +
649
+ "pass `entity` for those, and `facet` to file under a sub-page like pages/projects/chapterhouse/feature-ideas.md. " +
650
+ "A `decision` is always recorded against an entity: pass both `about` (which entity category) and `entity`; " +
651
+ "it lands at pages/<about>/<entity>/decisions.md. " +
655
652
  "For richer knowledge pages, use wiki_update instead.",
656
653
  parameters: z.object({
657
- category: z.enum(["preference", "fact", "project", "person", "routine", "decision"])
658
- .describe("Category: preference (likes/dislikes/settings), fact (general knowledge), project (codebase/repo info), person (people info), routine (schedules/habits), decision (choices made)"),
654
+ category: z.enum(["preference", "fact", "project", "person", "routine", "decision", "org", "tool", "topic", "area"])
655
+ .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
656
  content: z.string().describe("The thing to remember — a concise, self-contained statement"),
660
- entity: z.string().optional().describe("The specific entity this is about (e.g. 'team', 'chapterhouse', 'vercel'). Routes to a dedicated entity page."),
657
+ 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>/…"),
658
+ about: z.enum(["project", "person", "org", "tool", "topic", "area"]).optional()
659
+ .describe("Required only when category='decision': which entity category the decision concerns. The decision is filed at pages/<about>/<entity>/decisions.md."),
660
+ 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
661
  related: z.array(z.string()).optional().describe("Wiki page paths this connects to, for cross-referencing"),
662
662
  }),
663
663
  handler: async (args) => {
664
664
  return withWikiWrite(async () => {
665
665
  ensureWikiStructure();
666
666
  const now = new Date().toISOString().slice(0, 10);
667
- // Entity routing: code-authoritative slugification and page lookup
667
+ const titleCase = (s) => s.split(/[-_\s]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
668
+ // Routing: code-authoritative slugification and page lookup.
668
669
  let pagePath;
669
670
  let title;
670
- if (args.entity) {
671
- const slug = args.entity.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
672
- const categoryDir = getCategoryDir(args.category);
673
- pagePath = `pages/${categoryDir}/${slug}.md`;
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;
671
+ let routedCategoryDir; // the directory under pages/ this ends up in
672
+ if (args.category === "decision") {
673
+ if (!args.about || !args.entity) {
674
+ 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
675
  }
684
- else {
685
- title = args.entity.split(/[-_\s]+/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
676
+ routedCategoryDir = getCategoryDir(args.about);
677
+ if (!entityCategories().includes(routedCategoryDir)) {
678
+ return `'${args.about}' isn't a known entity category. Use one of: ${entityCategories().join(", ")}.`;
686
679
  }
680
+ pagePath = topicPagePath(args.about, args.entity, "decisions");
681
+ const existingMatch = searchIndex(args.entity, 5).find((p) => p.path === pagePath);
682
+ title = existingMatch ? existingMatch.title : `${titleCase(args.entity)} — Decisions`;
687
683
  }
688
684
  else {
689
- const categoryMap = {
690
- preference: "pages/preferences.md",
691
- fact: "pages/facts.md",
692
- project: "pages/projects.md",
693
- person: "pages/people.md",
694
- routine: "pages/routines.md",
695
- decision: "pages/decisions.md",
696
- };
697
- pagePath = categoryMap[args.category] || `pages/${args.category}.md`;
698
- title = args.category.charAt(0).toUpperCase() + args.category.slice(1);
685
+ routedCategoryDir = getCategoryDir(args.category);
686
+ const isFlat = FLAT_CATEGORIES.includes(routedCategoryDir);
687
+ if (!isFlat) {
688
+ if (!args.entity) {
689
+ 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".`;
690
+ }
691
+ const facet = args.facet ? slugify(args.facet) : "index";
692
+ pagePath = topicPagePath(args.category, args.entity, facet);
693
+ // Reuse an existing page if the index already has one at (or matching) this path.
694
+ const existingPages = searchIndex(args.entity, 5);
695
+ const existingMatch = existingPages.find((p) => p.path === pagePath ||
696
+ (facet === "index" && p.title.toLowerCase() === args.entity.toLowerCase() && p.path.startsWith(`pages/${routedCategoryDir}/`)));
697
+ if (existingMatch) {
698
+ pagePath = existingMatch.path;
699
+ title = existingMatch.title;
700
+ }
701
+ else {
702
+ const topicTitle = titleCase(args.entity);
703
+ title = facet === "index" ? topicTitle : `${topicTitle} — ${titleCase(facet)}`;
704
+ }
705
+ }
706
+ else {
707
+ pagePath = `pages/${routedCategoryDir}.md`;
708
+ title = titleCase(routedCategoryDir);
709
+ }
699
710
  }
700
711
  // Defense-in-depth: pagePath is constructed from controlled parts but
701
- // assertPagePath will catch any drift (e.g. an entity slug producing "..").
712
+ // assertPagePath also enforces the topic-structure rules.
702
713
  assertPagePath(pagePath);
703
714
  const existing = readPage(pagePath);
704
715
  if (existing) {
@@ -753,7 +764,7 @@ export function createTools(deps) {
753
764
  "Use when you need to look up something the user told you, or when asked 'do you remember...?'",
754
765
  parameters: z.object({
755
766
  keyword: z.string().optional().describe("Search term to match against wiki pages"),
756
- category: z.enum(["preference", "fact", "project", "person", "routine", "decision"]).optional()
767
+ category: z.enum(["preference", "fact", "project", "person", "routine", "decision", "org", "tool", "topic", "area"]).optional()
757
768
  .describe("Optional: filter by category"),
758
769
  }),
759
770
  handler: async (args) => {
@@ -916,9 +927,12 @@ export function createTools(deps) {
916
927
  }),
917
928
  defineTool("wiki_read", {
918
929
  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 (e.g. 'pages/preferences.md', 'index.md').",
930
+ "Paths are relative to the wiki root. Layout: entity categories (projects, people, orgs, tools, " +
931
+ "topics, areas) live at 'pages/<category>/<topic>/index.md' (the topic overview) plus optional " +
932
+ "'pages/<category>/<topic>/<facet>.md' sub-pages; flat categories at 'pages/<category>.md' " +
933
+ "(preferences, facts, routines, decisions); daily summaries at 'pages/conversations/YYYY-MM-DD.md'.",
920
934
  parameters: z.object({
921
- path: z.string().describe("Path to the wiki page (e.g. 'pages/people/brian.md', 'index.md')"),
935
+ path: z.string().describe("Path to the wiki page (e.g. 'pages/people/brian/index.md', 'pages/projects/chapterhouse/decisions.md', 'index.md')"),
922
936
  }),
923
937
  handler: async (args) => {
924
938
  ensureWikiStructure();
@@ -932,9 +946,14 @@ export function createTools(deps) {
932
946
  description: "Create or update a wiki page. You provide the full page content (markdown with optional " +
933
947
  "YAML frontmatter). The page will be written to disk and the index updated. Use this for " +
934
948
  "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.",
949
+ "a quick 'remember' call. After creating/updating a page, the index is automatically updated. " +
950
+ "PATH RULES: entity-category pages MUST be 'pages/<category>/<topic-slug>/<page>.md' where " +
951
+ "category is one of projects, people, orgs, tools, topics, areas, '<page>' is 'index' for the " +
952
+ "topic overview or a facet name (e.g. 'decisions', 'feature-ideas') — exactly one topic level, " +
953
+ "lowercase slugs only. Flat-category pages MUST be 'pages/<category>.md' (preferences, facts, " +
954
+ "routines, decisions). Bad paths are rejected with a suggested correction.",
936
955
  parameters: z.object({
937
- path: z.string().describe("Page path relative to wiki root (e.g. 'pages/projects/max.md')"),
956
+ 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
957
  title: z.string().describe("Page title for the index"),
939
958
  summary: z.string().describe("One-line summary for the index"),
940
959
  section: z.string().optional().describe("Index section (default: 'Knowledge')"),
@@ -944,20 +963,20 @@ export function createTools(deps) {
944
963
  return withWikiWrite(async () => {
945
964
  ensureWikiStructure();
946
965
  assertPagePath(args.path);
966
+ const validation = validateWikiFrontmatter(args.content, {
967
+ allowedTags: loadTaxonomy(),
968
+ });
969
+ if (!validation.valid) {
970
+ throw new Error(validation.errors.map((error) => error.message).join("\n\n"));
971
+ }
947
972
  writePage(args.path, args.content);
948
- // Rebuild from disk so the index summary/tags/updated reflect the actual page,
949
- // but prefer caller-supplied title/summary/section as overrides.
973
+ // Rebuild from disk so the index summary/tags/updated reflect the actual page.
950
974
  const today = new Date().toISOString().slice(0, 10);
951
975
  const rebuilt = buildIndexEntryForPage(args.path, {
952
- title: args.title,
953
- summary: indexSafe(args.summary).slice(0, 160),
954
976
  section: args.section || "Knowledge",
955
977
  updated: today,
956
978
  });
957
979
  if (rebuilt) {
958
- // Overrides win even if the page frontmatter says otherwise.
959
- rebuilt.title = args.title;
960
- rebuilt.summary = indexSafe(args.summary).slice(0, 160);
961
980
  rebuilt.section = args.section || "Knowledge";
962
981
  addToIndex(rebuilt);
963
982
  }
@@ -1048,25 +1067,11 @@ export function createTools(deps) {
1048
1067
  parameters: z.object({}),
1049
1068
  handler: async () => {
1050
1069
  ensureWikiStructure();
1051
- const indexEntries = parseIndex();
1052
- const pages = listPages();
1053
- const sources = listSources();
1054
- const indexPaths = new Set(indexEntries.map((e) => e.path));
1055
- const orphans = pages.filter((p) => !indexPaths.has(p));
1056
- const missing = indexEntries.filter((e) => !readPage(e.path));
1057
- const report = [`Wiki health report (${pages.length} pages, ${sources.length} sources):`];
1058
- if (orphans.length > 0) {
1059
- report.push(`\n**Orphan pages** (not in index):\n${orphans.map((p) => `- ${p}`).join("\n")}`);
1060
- }
1061
- if (missing.length > 0) {
1062
- report.push(`\n**Missing pages** (in index but not on disk):\n${missing.map((e) => `- ${e.path}: ${e.title}`).join("\n")}`);
1063
- }
1064
- if (orphans.length === 0 && missing.length === 0) {
1065
- report.push("\n✅ No issues found. Index and pages are in sync.");
1066
- }
1067
- report.push(`\n**Suggestions**: Look for pages that should link to each other, topics mentioned but lacking their own page, and stale content that needs updating.`);
1068
- appendLog("lint", `${orphans.length} orphans, ${missing.length} missing`);
1069
- return report.join("\n");
1070
+ const report = lintWiki();
1071
+ const orphanCount = report.issues.filter((issue) => issue.rule === "orphan-page").length;
1072
+ const missingCount = report.issues.filter((issue) => issue.rule === "missing-page").length;
1073
+ appendLog("lint", `${orphanCount} orphans, ${missingCount} missing`);
1074
+ return renderWikiLintReport(report);
1070
1075
  },
1071
1076
  }),
1072
1077
  defineTool("wiki_rebuild_index", {
@@ -1078,7 +1083,7 @@ export function createTools(deps) {
1078
1083
  return withWikiWrite(async () => {
1079
1084
  const { rebuildIndexFromPages } = await import("../wiki/index-manager.js");
1080
1085
  const entries = rebuildIndexFromPages();
1081
- appendLog("lint", `wiki_rebuild_index: rebuilt ${entries.length} entries from pages on disk`);
1086
+ appendLog("rebuild-index", `wiki_rebuild_index: rebuilt ${entries.length} entries from pages on disk`);
1082
1087
  return `Rebuilt index with ${entries.length} entries.`;
1083
1088
  });
1084
1089
  },
@@ -0,0 +1,143 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import test from "node:test";
6
+ async function loadToolsModule() {
7
+ return await import(new URL(`./tools.js?cachebust=${Date.now()}-${Math.random()}`, import.meta.url).href);
8
+ }
9
+ async function readWikiArtifacts() {
10
+ const nonce = `${Date.now()}-${Math.random()}`;
11
+ const wikiFs = await import(new URL(`../wiki/fs.js?case=${nonce}`, import.meta.url).href);
12
+ return wikiFs;
13
+ }
14
+ test.beforeEach(() => {
15
+ process.env.CHAPTERHOUSE_HOME = mkdtempSync(join(tmpdir(), "chapterhouse-tools-wiki-"));
16
+ process.env.CHAPTERHOUSE_AGENT_NAME = "tools-test-agent";
17
+ });
18
+ test.afterEach(async () => {
19
+ const home = process.env.CHAPTERHOUSE_HOME;
20
+ if (home) {
21
+ const dbModule = await import("../store/db.js");
22
+ dbModule.closeDb();
23
+ rmSync(home, { recursive: true, force: true });
24
+ }
25
+ });
26
+ test("wiki_update rejects content without required frontmatter", async () => {
27
+ const toolsModule = await loadToolsModule();
28
+ const tools = toolsModule.createTools({
29
+ client: { async listModels() { return []; } },
30
+ onAgentTaskComplete: () => { },
31
+ });
32
+ const tool = tools.find((entry) => entry.name === "wiki_update");
33
+ assert.ok(tool);
34
+ await assert.rejects(tool.handler({
35
+ path: "pages/shared/chapterhouse.md",
36
+ title: "Chapterhouse",
37
+ summary: "Runtime notes",
38
+ content: "# Chapterhouse\n\nRuntime notes.\n",
39
+ }), /Wiki page frontmatter violates the required shape/i);
40
+ });
41
+ test("wiki_update rejects malformed summaries and unknown tags", async () => {
42
+ const toolsModule = await loadToolsModule();
43
+ const tools = toolsModule.createTools({
44
+ client: { async listModels() { return []; } },
45
+ onAgentTaskComplete: () => { },
46
+ });
47
+ const tool = tools.find((entry) => entry.name === "wiki_update");
48
+ assert.ok(tool);
49
+ await assert.rejects(tool.handler({
50
+ path: "pages/shared/chapterhouse.md",
51
+ title: "Chapterhouse",
52
+ summary: "Runtime notes",
53
+ content: `---
54
+ title: Chapterhouse
55
+ summary: **Runtime notes**
56
+ tags: [engineering, made-up-tag]
57
+ ---
58
+
59
+ # Chapterhouse
60
+
61
+ Runtime notes.
62
+ `,
63
+ }), /Add it to `pages\/_meta\/taxonomy\.md` first\./);
64
+ });
65
+ test("wiki_update accepts valid frontmatter and refreshes the index entry", async () => {
66
+ const toolsModule = await loadToolsModule();
67
+ const tools = toolsModule.createTools({
68
+ client: { async listModels() { return []; } },
69
+ onAgentTaskComplete: () => { },
70
+ });
71
+ const tool = tools.find((entry) => entry.name === "wiki_update");
72
+ assert.ok(tool);
73
+ const result = await tool.handler({
74
+ path: "pages/shared/chapterhouse.md",
75
+ title: "Chapterhouse",
76
+ summary: "Runtime notes",
77
+ content: `---
78
+ title: Chapterhouse
79
+ summary: Runtime notes
80
+ tags: [engineering]
81
+ autostub: true
82
+ confidence: low
83
+ contested: true
84
+ contradictions: [pages/shared/other.md]
85
+ ---
86
+
87
+ # Chapterhouse
88
+
89
+ Runtime notes.
90
+ `,
91
+ });
92
+ assert.equal(result, "Wiki page updated: Chapterhouse (pages/shared/chapterhouse.md)");
93
+ const wikiFs = await readWikiArtifacts();
94
+ assert.match(wikiFs.readPage("pages/shared/chapterhouse.md") ?? "", /summary: Runtime notes/);
95
+ assert.match(wikiFs.readIndexFile(), /\[Chapterhouse\]\(pages\/shared\/chapterhouse\.md\) — Runtime notes/);
96
+ });
97
+ test("wiki tools append audit entries to pages/_meta/log.md", async () => {
98
+ const toolsModule = await loadToolsModule();
99
+ const tools = toolsModule.createTools({
100
+ client: { async listModels() { return []; } },
101
+ onAgentTaskComplete: () => { },
102
+ });
103
+ const wikiUpdate = tools.find((entry) => entry.name === "wiki_update");
104
+ const wikiIngest = tools.find((entry) => entry.name === "wiki_ingest");
105
+ const wikiLint = tools.find((entry) => entry.name === "wiki_lint");
106
+ const wikiRebuildIndex = tools.find((entry) => entry.name === "wiki_rebuild_index");
107
+ const forget = tools.find((entry) => entry.name === "forget");
108
+ assert.ok(wikiUpdate && wikiIngest && wikiLint && wikiRebuildIndex && forget);
109
+ await wikiUpdate.handler({
110
+ path: "pages/shared/chapterhouse.md",
111
+ title: "Chapterhouse",
112
+ summary: "Runtime notes",
113
+ content: `---
114
+ title: Chapterhouse
115
+ summary: Runtime notes
116
+ updated: 2026-05-12
117
+ tags: [engineering]
118
+ ---
119
+
120
+ # Chapterhouse
121
+
122
+ ## Overview
123
+
124
+ Runtime notes with enough content to avoid incidental lint noise in the audit-log test.
125
+ `,
126
+ });
127
+ await wikiIngest.handler({
128
+ type: "text",
129
+ source: "Source content for the wiki ingest audit log test.",
130
+ name: "audit-log-source",
131
+ });
132
+ await wikiLint.handler({});
133
+ await wikiRebuildIndex.handler({});
134
+ await forget.handler({ page_path: "pages/shared/chapterhouse.md" });
135
+ const wikiFs = await readWikiArtifacts();
136
+ const log = wikiFs.readLogFile();
137
+ assert.match(log, /update \| wiki_update: Chapterhouse \(pages\/shared\/chapterhouse\.md\) \| tools-test-agent/);
138
+ assert.match(log, /ingest \| Ingested text: audit-log-source \(\d+ chars\) \| tools-test-agent/);
139
+ assert.match(log, /lint \| .* \| tools-test-agent/);
140
+ assert.match(log, /rebuild-index \| wiki_rebuild_index: rebuilt \d+ entries from pages on disk \| tools-test-agent/);
141
+ assert.match(log, /delete \| forget: deleted page pages\/shared\/chapterhouse\.md \| tools-test-agent/);
142
+ });
143
+ //# sourceMappingURL=tools.wiki.test.js.map
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)
@@ -0,0 +1,148 @@
1
+ const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n?/;
2
+ const SUMMARY_MARKDOWN_RE = /(\*\*|__|[_*`~]|^\s*#+\s|\[[^\]]+\]\([^)]+\)|!\[[^\]]*\]\([^)]+\)|^\s*>)/m;
3
+ const FRONTMATTER_TEMPLATE = `---\ntitle: <title>\nsummary: <plain-text one-line summary, max 200 chars>\nupdated: YYYY-MM-DD\ntags: []\nrelated: []\n---`;
4
+ export function parseWikiFrontmatter(content) {
5
+ const match = content.match(FRONTMATTER_RE);
6
+ if (!match) {
7
+ return {
8
+ parsed: { metadata: {} },
9
+ body: content,
10
+ };
11
+ }
12
+ const parsed = { metadata: {} };
13
+ for (const line of match[1].split("\n")) {
14
+ const idx = line.indexOf(":");
15
+ if (idx <= 0)
16
+ continue;
17
+ const key = line.slice(0, idx).trim();
18
+ const rawValue = line.slice(idx + 1).trim();
19
+ const value = parseValue(rawValue);
20
+ switch (key) {
21
+ case "title":
22
+ case "summary":
23
+ case "updated":
24
+ if (typeof value === "string")
25
+ parsed[key] = value;
26
+ else
27
+ parsed.metadata[key] = value;
28
+ break;
29
+ case "tags":
30
+ case "contradictions":
31
+ case "related":
32
+ if (Array.isArray(value))
33
+ parsed[key] = value;
34
+ else
35
+ parsed.metadata[key] = value;
36
+ break;
37
+ case "autostub":
38
+ case "contested":
39
+ if (typeof value === "boolean")
40
+ parsed[key] = value;
41
+ else
42
+ parsed.metadata[key] = value;
43
+ break;
44
+ case "confidence":
45
+ if (value === "high" || value === "medium" || value === "low") {
46
+ parsed.confidence = value;
47
+ }
48
+ else {
49
+ parsed.metadata[key] = value;
50
+ }
51
+ break;
52
+ default:
53
+ parsed.metadata[key] = value;
54
+ break;
55
+ }
56
+ }
57
+ return {
58
+ parsed,
59
+ body: content.slice(match[0].length),
60
+ };
61
+ }
62
+ export function hasWikiFrontmatter(content) {
63
+ return FRONTMATTER_RE.test(content);
64
+ }
65
+ export function validateWikiFrontmatter(content, options = {}) {
66
+ const errors = [];
67
+ const { parsed } = parseWikiFrontmatter(content);
68
+ if (!hasWikiFrontmatter(content)) {
69
+ errors.push({
70
+ rule: "missing-frontmatter",
71
+ message: "Wiki page frontmatter violates the required shape: missing YAML frontmatter. Use:\n" + FRONTMATTER_TEMPLATE,
72
+ });
73
+ return { valid: false, errors };
74
+ }
75
+ if (!parsed.title?.trim()) {
76
+ errors.push({
77
+ rule: "missing-title",
78
+ field: "title",
79
+ message: formatFrontmatterMessage("missing 'title'"),
80
+ });
81
+ }
82
+ if (!parsed.summary?.trim()) {
83
+ errors.push({
84
+ rule: "missing-summary",
85
+ field: "summary",
86
+ message: formatFrontmatterMessage("missing 'summary'"),
87
+ });
88
+ }
89
+ else {
90
+ const summary = parsed.summary.trim();
91
+ if (summary.length > 200 ||
92
+ summary.includes("\n") ||
93
+ summary.includes("\r") ||
94
+ SUMMARY_MARKDOWN_RE.test(summary)) {
95
+ errors.push({
96
+ rule: "invalid-summary",
97
+ field: "summary",
98
+ message: formatFrontmatterMessage("invalid 'summary' (use plain text, one line, max 200 chars)"),
99
+ });
100
+ }
101
+ }
102
+ for (const field of ["updated", "tags", "autostub", "confidence", "contested", "contradictions", "related"]) {
103
+ if (field in parsed.metadata) {
104
+ errors.push({
105
+ rule: "invalid-field-type",
106
+ field,
107
+ message: formatFrontmatterMessage(`invalid '${field}' type`),
108
+ });
109
+ }
110
+ }
111
+ if (options.allowedTags && parsed.tags) {
112
+ const allowed = new Set(options.allowedTags.map((tag) => tag.toLowerCase()));
113
+ for (const tag of parsed.tags) {
114
+ if (!allowed.has(tag.toLowerCase())) {
115
+ errors.push({
116
+ rule: "unknown-tag",
117
+ field: "tags",
118
+ message: formatFrontmatterMessage(`unknown tag '${tag}'. Add it to \`pages/_meta/taxonomy.md\` first.`),
119
+ });
120
+ }
121
+ }
122
+ }
123
+ return {
124
+ valid: errors.length === 0,
125
+ errors,
126
+ };
127
+ }
128
+ function formatFrontmatterMessage(reason) {
129
+ return `Wiki page frontmatter violates the required shape: ${reason}. Use:\n${FRONTMATTER_TEMPLATE}`;
130
+ }
131
+ function parseValue(rawValue) {
132
+ if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
133
+ return rawValue
134
+ .slice(1, -1)
135
+ .split(",")
136
+ .map((item) => stripQuotes(item.trim()))
137
+ .filter(Boolean);
138
+ }
139
+ if (rawValue === "true")
140
+ return true;
141
+ if (rawValue === "false")
142
+ return false;
143
+ return stripQuotes(rawValue);
144
+ }
145
+ function stripQuotes(value) {
146
+ return value.replace(/^['"]|['"]$/g, "");
147
+ }
148
+ //# sourceMappingURL=frontmatter.js.map