chapterhouse 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +363 -0
  3. package/agents/chapterhouse.agent.md +40 -0
  4. package/agents/coder.agent.md +38 -0
  5. package/agents/designer.agent.md +43 -0
  6. package/agents/general-purpose.agent.md +30 -0
  7. package/dist/api/auth.js +159 -0
  8. package/dist/api/auth.test.js +463 -0
  9. package/dist/api/errors.js +95 -0
  10. package/dist/api/errors.test.js +89 -0
  11. package/dist/api/rate-limit.js +85 -0
  12. package/dist/api/server-runtime.js +62 -0
  13. package/dist/api/server.js +651 -0
  14. package/dist/api/server.test.js +385 -0
  15. package/dist/api/sse.integration.test.js +270 -0
  16. package/dist/api/sse.js +7 -0
  17. package/dist/api/team.js +196 -0
  18. package/dist/api/team.test.js +466 -0
  19. package/dist/cli.js +102 -0
  20. package/dist/config.js +299 -0
  21. package/dist/config.phase3.test.js +20 -0
  22. package/dist/config.test.js +148 -0
  23. package/dist/copilot/agents.js +447 -0
  24. package/dist/copilot/agents.squad.test.js +72 -0
  25. package/dist/copilot/classifier.js +72 -0
  26. package/dist/copilot/client.js +32 -0
  27. package/dist/copilot/client.test.js +100 -0
  28. package/dist/copilot/episode-writer.js +219 -0
  29. package/dist/copilot/episode-writer.test.js +41 -0
  30. package/dist/copilot/mcp-config.js +22 -0
  31. package/dist/copilot/okr-mapper.js +196 -0
  32. package/dist/copilot/okr-mapper.test.js +114 -0
  33. package/dist/copilot/orchestrator.js +685 -0
  34. package/dist/copilot/orchestrator.test.js +523 -0
  35. package/dist/copilot/router.js +142 -0
  36. package/dist/copilot/router.test.js +119 -0
  37. package/dist/copilot/skills.js +125 -0
  38. package/dist/copilot/standup.js +138 -0
  39. package/dist/copilot/standup.test.js +132 -0
  40. package/dist/copilot/system-message.js +143 -0
  41. package/dist/copilot/system-message.test.js +17 -0
  42. package/dist/copilot/tools.js +1212 -0
  43. package/dist/copilot/tools.okr.test.js +260 -0
  44. package/dist/copilot/tools.squad.test.js +168 -0
  45. package/dist/daemon.js +235 -0
  46. package/dist/home-path.js +12 -0
  47. package/dist/home-path.test.js +11 -0
  48. package/dist/integrations/ado-analytics.js +178 -0
  49. package/dist/integrations/ado-analytics.test.js +284 -0
  50. package/dist/integrations/ado-client.js +227 -0
  51. package/dist/integrations/ado-client.test.js +176 -0
  52. package/dist/integrations/ado-schema.js +25 -0
  53. package/dist/integrations/ado-schema.test.js +39 -0
  54. package/dist/integrations/ado-skill.js +55 -0
  55. package/dist/integrations/report-generator.js +114 -0
  56. package/dist/integrations/report-generator.test.js +62 -0
  57. package/dist/integrations/team-push.js +144 -0
  58. package/dist/integrations/team-push.test.js +178 -0
  59. package/dist/integrations/teams-notify.js +108 -0
  60. package/dist/integrations/teams-notify.test.js +135 -0
  61. package/dist/paths.js +41 -0
  62. package/dist/setup.js +149 -0
  63. package/dist/shutdown-signals.js +13 -0
  64. package/dist/shutdown-signals.test.js +33 -0
  65. package/dist/squad/charter.js +108 -0
  66. package/dist/squad/charter.test.js +89 -0
  67. package/dist/squad/context.js +48 -0
  68. package/dist/squad/context.test.js +59 -0
  69. package/dist/squad/discovery.js +280 -0
  70. package/dist/squad/discovery.test.js +93 -0
  71. package/dist/squad/index.js +7 -0
  72. package/dist/squad/mirror.js +81 -0
  73. package/dist/squad/mirror.scheduler.js +78 -0
  74. package/dist/squad/mirror.scheduler.test.js +197 -0
  75. package/dist/squad/mirror.test.js +172 -0
  76. package/dist/squad/registry.js +162 -0
  77. package/dist/squad/registry.test.js +31 -0
  78. package/dist/squad/squad-coordinator-system-message.test.js +190 -0
  79. package/dist/squad/squad-session-routing.test.js +260 -0
  80. package/dist/squad/types.js +4 -0
  81. package/dist/status.js +25 -0
  82. package/dist/status.test.js +22 -0
  83. package/dist/store/db.js +290 -0
  84. package/dist/store/db.test.js +126 -0
  85. package/dist/store/squad-sessions.test.js +341 -0
  86. package/dist/test/setup-env.js +3 -0
  87. package/dist/update.js +112 -0
  88. package/dist/update.test.js +25 -0
  89. package/dist/wiki/context.js +138 -0
  90. package/dist/wiki/fs.js +195 -0
  91. package/dist/wiki/fs.test.js +39 -0
  92. package/dist/wiki/index-manager.js +359 -0
  93. package/dist/wiki/index-manager.test.js +129 -0
  94. package/dist/wiki/lock.js +26 -0
  95. package/dist/wiki/lock.test.js +30 -0
  96. package/dist/wiki/log-manager.js +20 -0
  97. package/dist/wiki/migrate.js +306 -0
  98. package/dist/wiki/okr.test.js +101 -0
  99. package/dist/wiki/path-utils.js +4 -0
  100. package/dist/wiki/path-utils.test.js +8 -0
  101. package/dist/wiki/seed-team-wiki.js +296 -0
  102. package/dist/wiki/seed-team-wiki.test.js +69 -0
  103. package/dist/wiki/team-sync.js +212 -0
  104. package/dist/wiki/team-sync.test.js +185 -0
  105. package/dist/wiki/templates/okr.js +98 -0
  106. package/package.json +72 -0
  107. package/skills/.gitkeep +0 -0
  108. package/skills/find-skills/SKILL.md +161 -0
  109. package/skills/find-skills/_meta.json +4 -0
  110. package/skills/frontend-design/LICENSE.txt +177 -0
  111. package/skills/frontend-design/SKILL.md +42 -0
  112. package/skills/squad/SKILL.md +76 -0
  113. package/web/dist/assets/index-D-e7K-fT.css +10 -0
  114. package/web/dist/assets/index-DAg9IrpO.js +142 -0
  115. package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
  116. package/web/dist/chapterhouse-icon.png +0 -0
  117. package/web/dist/chapterhouse-icon.svg +42 -0
  118. package/web/dist/chapterhouse-logo.svg +46 -0
  119. package/web/dist/index.html +15 -0
@@ -0,0 +1,129 @@
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-index-${process.pid}`);
7
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
8
+ async function loadModules() {
9
+ const nonce = `${Date.now()}-${Math.random()}`;
10
+ const indexManager = await import(new URL(`./index-manager.js?case=${nonce}`, import.meta.url).href);
11
+ const wikiFs = await import(new URL(`./fs.js?case=${nonce}`, import.meta.url).href);
12
+ return { indexManager, 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("parseIndex reads sections, summaries, tags, and updated dates", async () => {
25
+ const { indexManager, wikiFs } = await loadModules();
26
+ wikiFs.writeIndexFile(`# Wiki Index\n\n## People\n\n- [Ada Lovelace](pages/people/ada.md) — Platform owner | tags: engineer, compiler | updated: 2026-05-01\n\n## Projects\n\n- [Roadmap](pages/projects/roadmap.md) - Shared priorities\n`);
27
+ assert.deepEqual(indexManager.parseIndex(), [
28
+ {
29
+ path: "pages/people/ada.md",
30
+ title: "Ada Lovelace",
31
+ summary: "Platform owner",
32
+ section: "People",
33
+ tags: ["engineer", "compiler"],
34
+ updated: "2026-05-01",
35
+ },
36
+ {
37
+ path: "pages/projects/roadmap.md",
38
+ title: "Roadmap",
39
+ summary: "Shared priorities",
40
+ section: "Projects",
41
+ tags: undefined,
42
+ updated: undefined,
43
+ },
44
+ ]);
45
+ });
46
+ test("buildIndexEntryForPage derives title, metadata, and a trimmed summary from page content", async () => {
47
+ const { indexManager, wikiFs } = await loadModules();
48
+ wikiFs.writePage("pages/shared/runbooks/deploy.md", `---\ntitle: Deploy Runbook\ntags: [ops, release]\nupdated: 2026-05-04\n---\n\n# Deploy\n\n${"Deploy carefully ".repeat(20)}\n`);
49
+ const entry = indexManager.buildIndexEntryForPage("pages/shared/runbooks/deploy.md");
50
+ assert.deepEqual(entry, {
51
+ path: "pages/shared/runbooks/deploy.md",
52
+ title: "Deploy Runbook",
53
+ summary: `${("Deploy carefully ".repeat(20)).trim().slice(0, 157)}…`,
54
+ section: "Knowledge",
55
+ tags: ["ops", "release"],
56
+ updated: "2026-05-04",
57
+ });
58
+ });
59
+ test("parseIndex self-heals an empty index from on-disk pages", async () => {
60
+ const { indexManager, wikiFs } = await loadModules();
61
+ wikiFs.writePage("pages/team/vision.md", "# Vision\n\nShared direction for the team.\n");
62
+ wikiFs.writeIndexFile("# Wiki Index\n\n");
63
+ const entries = indexManager.parseIndex();
64
+ assert.deepEqual(entries, [
65
+ {
66
+ path: "pages/team/vision.md",
67
+ title: "Vision",
68
+ summary: "Shared direction for the team.",
69
+ section: "Knowledge",
70
+ tags: undefined,
71
+ updated: undefined,
72
+ },
73
+ ]);
74
+ assert.match(wikiFs.readIndexFile(), /\[Vision\]\(pages\/team\/vision\.md\)/);
75
+ });
76
+ test("searchIndex ranks strong metadata matches and falls back to page bodies", async () => {
77
+ const { indexManager, wikiFs } = await loadModules();
78
+ wikiFs.writePage("pages/team/api.md", "# API\n\nObservability budget and telemetry plans.\n");
79
+ wikiFs.writePage("pages/team/ops.md", "# Ops\n\nDaily operational notes.\n");
80
+ indexManager.writeIndex([
81
+ {
82
+ path: "pages/team/api.md",
83
+ title: "API",
84
+ summary: "Status of the platform",
85
+ section: "Team",
86
+ tags: ["api"],
87
+ updated: new Date().toISOString().slice(0, 10),
88
+ },
89
+ {
90
+ path: "pages/team/ops.md",
91
+ title: "Operations",
92
+ summary: "Runbooks and incident work",
93
+ section: "Team",
94
+ },
95
+ ]);
96
+ const metadataHit = indexManager.searchIndex("api", 1);
97
+ const bodyFallback = indexManager.searchIndex("telemetry", 1);
98
+ assert.deepEqual(metadataHit.map((entry) => entry.path), ["pages/team/api.md"]);
99
+ assert.deepEqual(bodyFallback.map((entry) => entry.path), ["pages/team/api.md"]);
100
+ });
101
+ test("addToIndex, removeFromIndex, and getIndexSummary keep the catalog in sync", async () => {
102
+ const { indexManager } = await loadModules();
103
+ indexManager.addToIndex({
104
+ path: "pages/people/ada.md",
105
+ title: "Ada Lovelace",
106
+ summary: "Owns release quality",
107
+ section: "People",
108
+ tags: ["qa"],
109
+ updated: "2026-05-05",
110
+ });
111
+ indexManager.addToIndex({
112
+ path: "pages/projects/launch.md",
113
+ title: "Launch",
114
+ summary: "Tracks release milestones",
115
+ section: "Projects",
116
+ });
117
+ indexManager.addToIndex({
118
+ path: "pages/people/ada.md",
119
+ title: "Ada Lovelace",
120
+ summary: "Owns regression coverage",
121
+ section: "People",
122
+ tags: ["qa", "testing"],
123
+ updated: "2026-05-06",
124
+ });
125
+ assert.equal(indexManager.removeFromIndex("pages/projects/launch.md"), true);
126
+ assert.equal(indexManager.removeFromIndex("pages/projects/missing.md"), false);
127
+ assert.equal(indexManager.getIndexSummary(), "**People**: Ada Lovelace: Owns regression coverage [qa, testing] (2026-05-06)");
128
+ });
129
+ //# sourceMappingURL=index-manager.test.js.map
@@ -0,0 +1,26 @@
1
+ // ---------------------------------------------------------------------------
2
+ // In-process serializer for wiki mutations.
3
+ //
4
+ // All wiki state (pages + index.md + log.md) is shared mutable state in flat
5
+ // files. To prevent lost updates and torn writes when remember/forget/wiki_update
6
+ // and the async episode-writer overlap, every mutation must run through
7
+ // withWikiWrite(). Reads do NOT need to acquire the lock — they are protected
8
+ // by atomic file replacement at the FS level.
9
+ // ---------------------------------------------------------------------------
10
+ let chain = Promise.resolve();
11
+ /**
12
+ * Run an async wiki mutation under the global write lock.
13
+ * Calls are serialized FIFO. Errors propagate to the caller but do not
14
+ * break the chain for subsequent writers.
15
+ */
16
+ export function withWikiWrite(fn) {
17
+ const next = chain.then(() => fn(), () => fn());
18
+ // Keep the chain alive even if `next` rejects so the next caller can run.
19
+ chain = next.catch(() => undefined);
20
+ return next;
21
+ }
22
+ /** For tests/diagnostics: wait for the current write queue to drain. */
23
+ export function drainWikiWrites() {
24
+ return chain.then(() => undefined, () => undefined);
25
+ }
26
+ //# sourceMappingURL=lock.js.map
@@ -0,0 +1,30 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { drainWikiWrites, withWikiWrite } from "./lock.js";
4
+ test("withWikiWrite serializes concurrent writers in FIFO order", async () => {
5
+ const order = [];
6
+ await Promise.all([
7
+ withWikiWrite(async () => {
8
+ order.push("start-1");
9
+ await new Promise((resolve) => setTimeout(resolve, 20));
10
+ order.push("end-1");
11
+ }),
12
+ withWikiWrite(async () => {
13
+ order.push("start-2");
14
+ order.push("end-2");
15
+ }),
16
+ ]);
17
+ assert.deepEqual(order, ["start-1", "end-1", "start-2", "end-2"]);
18
+ });
19
+ test("withWikiWrite keeps the queue alive after a writer fails", async () => {
20
+ await assert.rejects(withWikiWrite(async () => {
21
+ throw new Error("boom");
22
+ }), /boom/);
23
+ const seen = [];
24
+ await withWikiWrite(async () => {
25
+ seen.push("ran");
26
+ });
27
+ await drainWikiWrites();
28
+ assert.deepEqual(seen, ["ran"]);
29
+ });
30
+ //# sourceMappingURL=lock.test.js.map
@@ -0,0 +1,20 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Wiki log.md manager — append-only chronological operation log
3
+ // ---------------------------------------------------------------------------
4
+ import { appendFileSync } from "fs";
5
+ import { join } from "path";
6
+ import { WIKI_DIR } from "../paths.js";
7
+ import { ensureWikiStructure } from "./fs.js";
8
+ const LOG_PATH = join(WIKI_DIR, "log.md");
9
+ /**
10
+ * Append a timestamped entry to log.md.
11
+ * Format: `## [YYYY-MM-DD HH:MM] type | description`
12
+ */
13
+ export function appendLog(type, description) {
14
+ ensureWikiStructure();
15
+ const now = new Date();
16
+ const ts = now.toISOString().slice(0, 16).replace("T", " ");
17
+ const entry = `## [${ts}] ${type} | ${description}\n\n`;
18
+ appendFileSync(LOG_PATH, entry, "utf-8");
19
+ }
20
+ //# sourceMappingURL=log-manager.js.map
@@ -0,0 +1,306 @@
1
+ // ---------------------------------------------------------------------------
2
+ // One-time migration: SQLite memories → wiki pages
3
+ // ---------------------------------------------------------------------------
4
+ import { getDb, getState, setState } from "../store/db.js";
5
+ import { ensureWikiStructure, writePage, readPage, writeRawSource, deletePage } from "./fs.js";
6
+ import { addToIndex, removeFromIndex } from "./index-manager.js";
7
+ import { appendLog } from "./log-manager.js";
8
+ const MIGRATION_KEY = "wiki_migrated";
9
+ const REORG_KEY = "wiki_reorganized";
10
+ /** Check whether a migration is needed (wiki not yet populated from SQLite). */
11
+ export function shouldMigrate() {
12
+ return getState(MIGRATION_KEY) !== "true";
13
+ }
14
+ /** Check whether reorganization is needed. */
15
+ export function shouldReorganize() {
16
+ return getState(MIGRATION_KEY) === "true" && getState(REORG_KEY) !== "true";
17
+ }
18
+ /** Category → wiki page path and section name */
19
+ const CATEGORY_MAP = {
20
+ preference: { path: "pages/preferences.md", title: "Preferences", section: "Knowledge" },
21
+ fact: { path: "pages/facts.md", title: "Facts", section: "Knowledge" },
22
+ project: { path: "pages/projects.md", title: "Projects", section: "Knowledge" },
23
+ person: { path: "pages/people.md", title: "People", section: "Knowledge" },
24
+ routine: { path: "pages/routines.md", title: "Routines", section: "Knowledge" },
25
+ };
26
+ /**
27
+ * Migrate all existing SQLite memories into wiki pages.
28
+ * Groups memories by category, creates one page per category.
29
+ * Returns the number of memories migrated.
30
+ */
31
+ export function migrateMemoriesToWiki() {
32
+ ensureWikiStructure();
33
+ const db = getDb();
34
+ const rows = db.prepare(`SELECT id, category, content, source, created_at FROM memories ORDER BY category, id`).all();
35
+ if (rows.length === 0) {
36
+ setState(MIGRATION_KEY, "true");
37
+ appendLog("migrate", "No memories to migrate (empty table).");
38
+ return 0;
39
+ }
40
+ // Group by category
41
+ const grouped = {};
42
+ for (const row of rows) {
43
+ if (!grouped[row.category])
44
+ grouped[row.category] = [];
45
+ grouped[row.category].push(row);
46
+ }
47
+ const now = new Date().toISOString().slice(0, 10);
48
+ for (const [category, items] of Object.entries(grouped)) {
49
+ const mapping = CATEGORY_MAP[category] || {
50
+ path: `pages/${category}.md`,
51
+ title: category.charAt(0).toUpperCase() + category.slice(1),
52
+ section: "Knowledge",
53
+ };
54
+ // Build the page content
55
+ const lines = [
56
+ "---",
57
+ `title: ${mapping.title}`,
58
+ `tags: [${category}, migrated]`,
59
+ `created: ${now}`,
60
+ `updated: ${now}`,
61
+ "---",
62
+ "",
63
+ `# ${mapping.title}`,
64
+ "",
65
+ `_Migrated from Chapterhouse's memory store on ${now}._`,
66
+ "",
67
+ ];
68
+ for (const item of items) {
69
+ lines.push(`- ${item.content} _(${item.source}, ${item.created_at.slice(0, 10)})_`);
70
+ }
71
+ lines.push("");
72
+ // Check if a page already exists (avoid clobbering manual content)
73
+ const existing = readPage(mapping.path);
74
+ // Idempotency marker: if the migration block was already appended, skip the
75
+ // append so re-runs don't duplicate bullets.
76
+ const MIGRATE_MARKER = `<!-- migrate:${category}:v1 -->`;
77
+ if (existing) {
78
+ if (existing.includes(MIGRATE_MARKER)) {
79
+ // Already migrated; just refresh the index entry.
80
+ const entry = {
81
+ path: mapping.path,
82
+ title: mapping.title,
83
+ summary: `${items.length} ${category} memories (already migrated)`,
84
+ section: mapping.section,
85
+ };
86
+ addToIndex(entry);
87
+ continue;
88
+ }
89
+ // Extract only the bullet-point items to append
90
+ const bulletLines = lines.filter((l) => l.startsWith("- "));
91
+ writePage(mapping.path, existing + `\n${MIGRATE_MARKER}\n## Migrated Memories\n\n` + bulletLines.join("\n") + "\n");
92
+ }
93
+ else {
94
+ // Embed the marker in fresh pages too so future re-runs are no-ops.
95
+ lines.splice(lines.length - 1, 0, MIGRATE_MARKER);
96
+ writePage(mapping.path, lines.join("\n"));
97
+ }
98
+ // Update index
99
+ const entry = {
100
+ path: mapping.path,
101
+ title: mapping.title,
102
+ summary: `${items.length} ${category} memories (migrated from SQLite)`,
103
+ section: mapping.section,
104
+ };
105
+ addToIndex(entry);
106
+ }
107
+ const total = rows.length;
108
+ const categories = Object.keys(grouped).join(", ");
109
+ appendLog("migrate", `Migrated ${total} memories across categories: ${categories}`);
110
+ setState(MIGRATION_KEY, "true");
111
+ console.log(`[chapterhouse] Wiki migration complete: ${total} memories → ${Object.keys(grouped).length} pages`);
112
+ return total;
113
+ }
114
+ // ---------------------------------------------------------------------------
115
+ // One-time reorganization: flat dump pages → entity pages
116
+ // ---------------------------------------------------------------------------
117
+ // Patterns for junk content to filter out during reorg
118
+ const JUNK_PATTERNS = [
119
+ /smoke\s*test/i,
120
+ /re-?smoke/i,
121
+ /final\s*smoke/i,
122
+ /test.*memory/i,
123
+ /testing.*remember/i,
124
+ ];
125
+ function isJunk(line) {
126
+ return JUNK_PATTERNS.some((p) => p.test(line));
127
+ }
128
+ /** Parse bullet points from a wiki page body (stripping frontmatter). */
129
+ function extractBullets(content) {
130
+ const body = content.replace(/^---[\s\S]*?---\s*/, "");
131
+ return body.split("\n")
132
+ .filter((l) => l.trim().startsWith("- "))
133
+ .map((l) => l.trim());
134
+ }
135
+ /** Detect entity mentions in bullet text for routing. */
136
+ function detectEntity(bullet, category) {
137
+ // People: look for capitalized names
138
+ if (category === "person" || category === "people") {
139
+ const nameMatch = bullet.match(/^-\s+(.+?)\s+(?:is|prefers|likes|works|lives|uses|—)/i);
140
+ if (nameMatch) {
141
+ const name = nameMatch[1].replace(/^['"]|['"]$/g, "").trim();
142
+ if (name.length > 1 && name.length < 40 && /^[A-Z]/.test(name))
143
+ return name;
144
+ }
145
+ }
146
+ // Projects: look for project names
147
+ if (category === "project" || category === "projects") {
148
+ const projMatch = bullet.match(/^-\s+(?:Project\s+)?(.+?)\s+(?:is|uses|runs|—)/i);
149
+ if (projMatch) {
150
+ const name = projMatch[1].replace(/^['"]|['"]$/g, "").trim();
151
+ if (name.length > 1 && name.length < 40)
152
+ return name;
153
+ }
154
+ }
155
+ return undefined;
156
+ }
157
+ /**
158
+ * Reorganize wiki pages from flat category dumps into entity pages.
159
+ * Archives originals to sources/migrated-archive/, filters junk,
160
+ * splits into entity pages where possible.
161
+ */
162
+ export function reorganizeWiki() {
163
+ ensureWikiStructure();
164
+ const dumpPages = [
165
+ "pages/preferences.md",
166
+ "pages/facts.md",
167
+ "pages/projects.md",
168
+ "pages/people.md",
169
+ "pages/routines.md",
170
+ "pages/decision.md",
171
+ "pages/task.md",
172
+ ];
173
+ const now = new Date().toISOString().slice(0, 10);
174
+ let pagesCreated = 0;
175
+ for (const pagePath of dumpPages) {
176
+ const content = readPage(pagePath);
177
+ if (!content)
178
+ continue;
179
+ // Archive the original
180
+ const archiveName = `migrated-archive/${pagePath.replace("pages/", "").replace(/\//g, "-")}`;
181
+ writeRawSource(archiveName, content);
182
+ const category = pagePath.replace("pages/", "").replace(".md", "");
183
+ const bullets = extractBullets(content);
184
+ const validBullets = bullets.filter((b) => !isJunk(b));
185
+ if (validBullets.length === 0) {
186
+ // All junk — remove the page
187
+ deletePage(pagePath);
188
+ removeFromIndex(pagePath);
189
+ appendLog("reorg", `Removed junk page: ${pagePath}`);
190
+ continue;
191
+ }
192
+ // Try to split into entity pages
193
+ const entityGroups = new Map();
194
+ const ungrouped = [];
195
+ for (const bullet of validBullets) {
196
+ const entity = detectEntity(bullet, category);
197
+ if (entity) {
198
+ const list = entityGroups.get(entity) || [];
199
+ list.push(bullet);
200
+ entityGroups.set(entity, list);
201
+ }
202
+ else {
203
+ ungrouped.push(bullet);
204
+ }
205
+ }
206
+ // Write entity pages
207
+ const categoryDir = getCategoryDirForReorg(category);
208
+ for (const [entity, entityBullets] of entityGroups) {
209
+ const slug = entity.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
210
+ const entityPath = `pages/${categoryDir}/${slug}.md`;
211
+ const existing = readPage(entityPath);
212
+ const REORG_MARKER = `<!-- reorg:${entity.toLowerCase()}:v1 -->`;
213
+ if (existing) {
214
+ if (existing.includes(REORG_MARKER)) {
215
+ // Already reorganized into this entity page; skip duplicate append.
216
+ continue;
217
+ }
218
+ // Append to existing entity page
219
+ const updated = existing.replace(/^(---[\s\S]*?updated:\s*)[\d-]+/m, `$1${now}`);
220
+ writePage(entityPath, updated.trimEnd() + `\n${REORG_MARKER}\n` + entityBullets.join("\n") + "\n");
221
+ }
222
+ else {
223
+ const page = [
224
+ "---",
225
+ `title: ${entity}`,
226
+ `tags: [${category}, migrated]`,
227
+ `created: ${now}`,
228
+ `updated: ${now}`,
229
+ "related: []",
230
+ "---",
231
+ "",
232
+ `# ${entity}`,
233
+ "",
234
+ REORG_MARKER,
235
+ "",
236
+ ...entityBullets,
237
+ "",
238
+ ].join("\n");
239
+ writePage(entityPath, page);
240
+ pagesCreated++;
241
+ }
242
+ addToIndex({
243
+ path: entityPath,
244
+ title: entity,
245
+ summary: `${entityBullets.length} entries about ${entity}`,
246
+ section: "Knowledge",
247
+ tags: [category, "migrated"],
248
+ updated: now,
249
+ });
250
+ }
251
+ // Keep ungrouped bullets in the category page (rewritten clean)
252
+ if (ungrouped.length > 0) {
253
+ const title = category.charAt(0).toUpperCase() + category.slice(1);
254
+ const page = [
255
+ "---",
256
+ `title: ${title}`,
257
+ `tags: [${category}]`,
258
+ `created: ${now}`,
259
+ `updated: ${now}`,
260
+ "related: []",
261
+ "---",
262
+ "",
263
+ `# ${title}`,
264
+ "",
265
+ ...ungrouped,
266
+ "",
267
+ ].join("\n");
268
+ writePage(pagePath, page);
269
+ addToIndex({
270
+ path: pagePath,
271
+ title,
272
+ summary: `${ungrouped.length} ${category} entries`,
273
+ section: "Knowledge",
274
+ tags: [category],
275
+ updated: now,
276
+ });
277
+ }
278
+ else {
279
+ // All bullets were entity-routed, remove the dump page
280
+ deletePage(pagePath);
281
+ removeFromIndex(pagePath);
282
+ }
283
+ }
284
+ setState(REORG_KEY, "true");
285
+ appendLog("reorg", `Wiki reorganized: ${pagesCreated} entity pages created`);
286
+ console.log(`[chapterhouse] Wiki reorganization complete: ${pagesCreated} entity pages created`);
287
+ return pagesCreated;
288
+ }
289
+ function getCategoryDirForReorg(category) {
290
+ const map = {
291
+ person: "people",
292
+ people: "people",
293
+ project: "projects",
294
+ projects: "projects",
295
+ preference: "preferences",
296
+ preferences: "preferences",
297
+ fact: "facts",
298
+ facts: "facts",
299
+ routine: "routines",
300
+ routines: "routines",
301
+ decision: "decisions",
302
+ task: "tasks",
303
+ };
304
+ return map[category] || category;
305
+ }
306
+ //# sourceMappingURL=migrate.js.map
@@ -0,0 +1,101 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ async function loadOkrTemplateModule() {
4
+ try {
5
+ return await import(new URL(`./templates/okr.js?cachebust=${Date.now()}-${Math.random()}`, import.meta.url).href);
6
+ }
7
+ catch {
8
+ return null;
9
+ }
10
+ }
11
+ test("generateOKRQuarterPage renders objectives, key results, and quarter period", async () => {
12
+ const okrTemplateModule = await loadOkrTemplateModule();
13
+ assert.ok(okrTemplateModule, "OKR template module should exist");
14
+ const page = okrTemplateModule.generateOKRQuarterPage("2026-Q2", [
15
+ {
16
+ id: "O1",
17
+ title: "Raise Azure platform delivery confidence",
18
+ owner: "Ava Wilson",
19
+ keyResults: [
20
+ {
21
+ id: "O1-KR1",
22
+ title: "Reduce Sev2 incidents in regional deployments",
23
+ owner: "Noah Patel",
24
+ targetValue: 95,
25
+ currentValue: 12,
26
+ unit: "%",
27
+ dueDate: "2026-06-30",
28
+ },
29
+ ],
30
+ },
31
+ ]);
32
+ assert.match(page, /^# OKRs — 2026 Q2/m);
33
+ assert.match(page, /> Period: 2026-04-01 to 2026-06-30/);
34
+ assert.match(page, /## O1: Raise Azure platform delivery confidence/);
35
+ assert.match(page, /\*\*Owner\*\*: Ava Wilson/);
36
+ assert.match(page, /### O1-KR1: Reduce Sev2 incidents in regional deployments/);
37
+ assert.match(page, /- \*\*Target\*\*: 95%/);
38
+ assert.match(page, /- \*\*Current\*\*: 12%/);
39
+ assert.match(page, /- \*\*ADO Work Item\*\*: <!-- fill in after ADO setup -->/);
40
+ });
41
+ test("generateKPIPage renders a team KPI table", async () => {
42
+ const okrTemplateModule = await loadOkrTemplateModule();
43
+ assert.ok(okrTemplateModule, "OKR template module should exist");
44
+ const page = okrTemplateModule.generateKPIPage([
45
+ {
46
+ id: "kpi-release-cadence",
47
+ name: "Release cadence",
48
+ owner: "Ava Wilson",
49
+ target: 8,
50
+ current: 5,
51
+ unit: "deployments",
52
+ frequency: "monthly",
53
+ },
54
+ ]);
55
+ assert.match(page, /^# Team KPIs/m);
56
+ assert.match(page, /\| KPI \| Owner \| Target \| Current \| Unit \| Frequency \|/);
57
+ assert.match(page, /\| Release cadence \| Ava Wilson \| 8 \| 5 \| deployments \| monthly \|/);
58
+ });
59
+ test("generateTeamMemberPage renders ownership metadata for a team member", async () => {
60
+ const okrTemplateModule = await loadOkrTemplateModule();
61
+ assert.ok(okrTemplateModule, "OKR template module should exist");
62
+ const page = okrTemplateModule.generateTeamMemberPage({
63
+ name: "Ava Wilson",
64
+ email: "ava.wilson@example.com",
65
+ role: "team-lead",
66
+ entraObjectId: "11111111-2222-3333-4444-555555555555",
67
+ okrOwnership: ["O1-KR1", "O2-KR2"],
68
+ });
69
+ assert.match(page, /^# Ava Wilson/m);
70
+ assert.match(page, /\*\*Email\*\*: ava\.wilson@example\.com/);
71
+ assert.match(page, /\*\*Role\*\*: team-lead/);
72
+ assert.match(page, /\*\*Entra Object ID\*\*: 11111111-2222-3333-4444-555555555555/);
73
+ assert.match(page, /## OKR Ownership/);
74
+ assert.match(page, /- O1-KR1/);
75
+ assert.match(page, /- O2-KR2/);
76
+ });
77
+ test("generateTeamIndexPage renders a team roster table", async () => {
78
+ const okrTemplateModule = await loadOkrTemplateModule();
79
+ assert.ok(okrTemplateModule, "OKR template module should exist");
80
+ const page = okrTemplateModule.generateTeamIndexPage([
81
+ {
82
+ name: "Ava Wilson",
83
+ email: "ava.wilson@example.com",
84
+ role: "team-lead",
85
+ entraObjectId: "11111111-2222-3333-4444-555555555555",
86
+ okrOwnership: ["O1-KR1", "O2-KR2"],
87
+ },
88
+ {
89
+ name: "Noah Patel",
90
+ email: "noah.patel@example.com",
91
+ role: "engineer",
92
+ entraObjectId: "66666666-7777-8888-9999-000000000000",
93
+ okrOwnership: ["O1-KR3"],
94
+ },
95
+ ]);
96
+ assert.match(page, /^# Team Directory/m);
97
+ assert.match(page, /\| Name \| Role \| Email \| Entra Object ID \| OKR Ownership \|/);
98
+ assert.match(page, /\| Ava Wilson \| team-lead \| ava\.wilson@example\.com \| 11111111-2222-3333-4444-555555555555 \| O1-KR1, O2-KR2 \|/);
99
+ assert.match(page, /\| Noah Patel \| engineer \| noah\.patel@example\.com \| 66666666-7777-8888-9999-000000000000 \| O1-KR3 \|/);
100
+ });
101
+ //# sourceMappingURL=okr.test.js.map
@@ -0,0 +1,4 @@
1
+ export function normalizeWikiPath(path) {
2
+ return path.replace(/\\/g, "/");
3
+ }
4
+ //# sourceMappingURL=path-utils.js.map
@@ -0,0 +1,8 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { normalizeWikiPath } from "./path-utils.js";
4
+ test("normalizeWikiPath converts Windows separators in logical wiki IDs", () => {
5
+ assert.equal(normalizeWikiPath("pages\\standups\\2026-05-06.md"), "pages/standups/2026-05-06.md");
6
+ assert.equal(normalizeWikiPath("pages/conversations/2026-05-06.md"), "pages/conversations/2026-05-06.md");
7
+ });
8
+ //# sourceMappingURL=path-utils.test.js.map