chapterhouse 0.9.1 → 0.10.0

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 (112) hide show
  1. package/README.md +1 -1
  2. package/agents/korg.agent.md +20 -0
  3. package/dist/api/auth.js +11 -1
  4. package/dist/api/auth.test.js +29 -0
  5. package/dist/api/errors.js +23 -0
  6. package/dist/api/route-coverage.test.js +61 -21
  7. package/dist/api/routes/agents.js +472 -0
  8. package/dist/api/routes/memory.js +299 -0
  9. package/dist/api/routes/projects.js +170 -0
  10. package/dist/api/routes/sessions.js +347 -0
  11. package/dist/api/routes/system.js +82 -0
  12. package/dist/api/routes/wiki.js +455 -0
  13. package/dist/api/routes/wiki.test.js +49 -0
  14. package/dist/api/send-json.js +16 -0
  15. package/dist/api/send-json.test.js +18 -0
  16. package/dist/api/server-runtime.js +45 -3
  17. package/dist/api/server.js +34 -1764
  18. package/dist/api/server.test.js +239 -8
  19. package/dist/api/sse-hub.js +37 -0
  20. package/dist/cli.js +1 -1
  21. package/dist/config.js +151 -58
  22. package/dist/config.test.js +29 -0
  23. package/dist/copilot/okr-mapper.js +2 -11
  24. package/dist/copilot/orchestrator.js +358 -352
  25. package/dist/copilot/orchestrator.test.js +139 -4
  26. package/dist/copilot/prompt-date.js +2 -1
  27. package/dist/copilot/session-manager.js +25 -23
  28. package/dist/copilot/session-manager.test.js +35 -1
  29. package/dist/copilot/standup.js +2 -2
  30. package/dist/copilot/task-event-log.js +7 -1
  31. package/dist/copilot/task-event-log.test.js +13 -0
  32. package/dist/copilot/tools/agent.js +608 -0
  33. package/dist/copilot/tools/index.js +19 -0
  34. package/dist/copilot/tools/memory.js +678 -0
  35. package/dist/copilot/tools/models.js +2 -0
  36. package/dist/copilot/tools/okr.js +171 -0
  37. package/dist/copilot/tools/wiki.js +333 -0
  38. package/dist/copilot/tools-deps.js +4 -0
  39. package/dist/copilot/tools.agent.test.js +10 -8
  40. package/dist/copilot/tools.inventory.test.js +76 -0
  41. package/dist/copilot/tools.js +1 -1725
  42. package/dist/copilot/tools.okr.test.js +31 -0
  43. package/dist/copilot/tools.wiki.test.js +358 -6
  44. package/dist/copilot/turn-event-log.js +31 -4
  45. package/dist/copilot/turn-event-log.test.js +24 -2
  46. package/dist/copilot/workiq-installer.test.js +2 -2
  47. package/dist/daemon-install.js +3 -2
  48. package/dist/daemon.js +9 -17
  49. package/dist/integrations/ado-client.js +90 -9
  50. package/dist/integrations/ado-client.test.js +56 -0
  51. package/dist/integrations/team-push.js +1 -0
  52. package/dist/integrations/team-push.test.js +6 -0
  53. package/dist/integrations/teams-notify.js +1 -0
  54. package/dist/integrations/teams-notify.test.js +5 -0
  55. package/dist/memory/active-scope.test.js +0 -1
  56. package/dist/memory/checkpoint.js +89 -72
  57. package/dist/memory/checkpoint.test.js +23 -3
  58. package/dist/memory/eot.js +194 -89
  59. package/dist/memory/eot.test.js +186 -3
  60. package/dist/memory/hooks.js +2 -4
  61. package/dist/memory/housekeeping-scheduler.js +1 -1
  62. package/dist/memory/housekeeping-scheduler.test.js +1 -2
  63. package/dist/memory/housekeeping.js +100 -3
  64. package/dist/memory/housekeeping.test.js +33 -2
  65. package/dist/memory/reflect.test.js +2 -0
  66. package/dist/memory/scope-lock.js +26 -0
  67. package/dist/memory/scope-lock.test.js +118 -0
  68. package/dist/memory/scopes.test.js +0 -1
  69. package/dist/mode-context.js +58 -5
  70. package/dist/mode-context.test.js +68 -0
  71. package/dist/paths.js +1 -0
  72. package/dist/setup.js +3 -2
  73. package/dist/shared/api-schemas.js +48 -5
  74. package/dist/store/connection.js +96 -0
  75. package/dist/store/db.js +5 -1498
  76. package/dist/store/db.test.js +182 -1
  77. package/dist/store/migrations.js +460 -0
  78. package/dist/store/repositories/memory.js +281 -0
  79. package/dist/store/repositories/okr.js +3 -0
  80. package/dist/store/repositories/projects.js +5 -0
  81. package/dist/store/repositories/sessions.js +284 -0
  82. package/dist/store/repositories/wiki.js +60 -0
  83. package/dist/store/schema.js +501 -0
  84. package/dist/util/logger.js +3 -2
  85. package/dist/wiki/consolidation.js +50 -9
  86. package/dist/wiki/consolidation.test.js +45 -0
  87. package/dist/wiki/frontmatter.js +45 -14
  88. package/dist/wiki/frontmatter.test.js +26 -1
  89. package/dist/wiki/fs.js +16 -4
  90. package/dist/wiki/fs.test.js +84 -0
  91. package/dist/wiki/index-manager.js +30 -2
  92. package/dist/wiki/index-manager.test.js +43 -12
  93. package/dist/wiki/ingest.js +17 -1
  94. package/dist/wiki/lock.js +11 -1
  95. package/dist/wiki/log-manager.js +2 -7
  96. package/dist/wiki/migrate.js +44 -17
  97. package/dist/wiki/project-registry.js +10 -5
  98. package/dist/wiki/project-registry.test.js +14 -0
  99. package/dist/wiki/scheduler.js +1 -1
  100. package/dist/wiki/seed-team-wiki.js +2 -1
  101. package/dist/wiki/team-sync.js +31 -6
  102. package/dist/wiki/team-sync.test.js +81 -0
  103. package/package.json +1 -1
  104. package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
  105. package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
  106. package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
  107. package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
  108. package/web/dist/assets/index-CUm2Wbuh.js +250 -0
  109. package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
  110. package/web/dist/index.html +1 -1
  111. package/web/dist/assets/index-iQrv3lQN.js +0 -286
  112. package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
@@ -0,0 +1,171 @@
1
+ import { defineTool } from "@github/copilot-sdk";
2
+ import { z } from "zod";
3
+ import { config } from "../../config.js";
4
+ import { ModeContext } from "../../mode-context.js";
5
+ import { adoGetOkrs, adoOkrSummary, adoUpdateKr } from "../../integrations/ado-skill.js";
6
+ import { TeamPushClient } from "../../integrations/team-push.js";
7
+ import { getCurrentAuthenticatedUser, getCurrentAuthorizationHeader } from "../orchestrator.js";
8
+ import { OKRMapper, parseOKRPageContent } from "../okr-mapper.js";
9
+ import { teamWikiSync } from "../../wiki/team-sync.js";
10
+ const modeContext = new ModeContext(config);
11
+ function getCurrentQuarter(now = new Date()) {
12
+ return `${now.getUTCFullYear()}-Q${Math.floor(now.getUTCMonth() / 3) + 1}`;
13
+ }
14
+ function isSharedTeamWikiPath(path) {
15
+ return path.startsWith("pages/shared/");
16
+ }
17
+ export async function getMyOkrsSummary(options) {
18
+ const user = options.getCurrentUser();
19
+ if (!user) {
20
+ return "I don't have the current user identity for this session, so I can't filter your OKRs.";
21
+ }
22
+ const content = await options.createTeamPushClient().fetchOKRs(options.period);
23
+ const owned = parseOKRPageContent(content)
24
+ .filter((kr) => isOwnedByCurrentUser(kr.owner, user))
25
+ .sort((a, b) => a.krId.localeCompare(b.krId));
26
+ if (owned.length === 0) {
27
+ return `No current OKRs found for ${user.name}.`;
28
+ }
29
+ const lines = owned.map((kr) => {
30
+ const progress = Number.isFinite(kr.currentValue) && Number.isFinite(kr.targetValue)
31
+ ? ` — ${kr.currentValue}/${kr.targetValue}${kr.unit ? ` ${kr.unit}` : ""}`
32
+ : "";
33
+ return `• ${kr.krId}: ${kr.title} (${kr.objectiveTitle})${progress}`;
34
+ });
35
+ return `Current OKRs for ${user.name}:\n${lines.join("\n")}`;
36
+ }
37
+ function isOwnedByCurrentUser(owner, user) {
38
+ const normalizedOwner = normalizeIdentity(owner);
39
+ return [user.id, user.name, user.email]
40
+ .map((value) => normalizeIdentity(value))
41
+ .filter(Boolean)
42
+ .includes(normalizedOwner);
43
+ }
44
+ function normalizeIdentity(value) {
45
+ return (value ?? "").trim().toLowerCase();
46
+ }
47
+ function hasAdoPat() {
48
+ return config.adoPat.length > 0;
49
+ }
50
+ export function createOkrTools(deps) {
51
+ const getCurrentUser = deps.getCurrentUser ?? (() => getCurrentAuthenticatedUser());
52
+ const createTeamPushClient = deps.createTeamPushClient ?? (() => new TeamPushClient({
53
+ getAuthorizationHeader: getCurrentAuthorizationHeader,
54
+ getCurrentUser,
55
+ }));
56
+ const createOKRMapper = deps.createOKRMapper ?? (() => new OKRMapper(teamWikiSync));
57
+ return [
58
+ defineTool("log_okr_progress", {
59
+ description: "Log progress on a team OKR key result. Use when the user mentions completing work, shipping features, or making progress on goals.",
60
+ parameters: z.object({
61
+ activity: z.string().min(1).describe("Human description of what was done"),
62
+ krId: z.string().optional().describe("Key result identifier"),
63
+ delta: z.number().finite().optional().describe("Progress delta in the range 0-100"),
64
+ notes: z.string().optional().describe("Optional notes about the work"),
65
+ }),
66
+ handler: async (args) => {
67
+ if (!modeContext.canLogToAdo()) {
68
+ return "OKR progress logging is only available from personal Chapterhouse instances.";
69
+ }
70
+ const mapper = createOKRMapper();
71
+ if (!args.krId) {
72
+ const matches = await mapper.findMatchingKRs(args.activity);
73
+ return matches.length > 0
74
+ ? mapper.formatUpdatePrompt(args.activity, matches)
75
+ : `You mentioned: "${args.activity}". I couldn't confidently map that to a team key result yet. Tell me the KR id and delta (0-100), and I'll log it.`;
76
+ }
77
+ if (args.delta === undefined) {
78
+ return `I can log "${args.activity}" against ${args.krId}. What's the delta (0-100)?`;
79
+ }
80
+ const result = await createTeamPushClient().pushUpdate({
81
+ activity: args.activity,
82
+ krId: args.krId,
83
+ delta: args.delta,
84
+ notes: args.notes,
85
+ });
86
+ mapper.recordConfirmedMapping(args.activity, args.krId);
87
+ const deltaText = typeof result.entry?.delta === "number" ? ` (${result.entry.delta}% logged)` : "";
88
+ return `Logged OKR progress for ${args.krId}${deltaText}.`;
89
+ },
90
+ }),
91
+ defineTool("get_my_okrs", {
92
+ description: "Show the current OKR key results owned by this user",
93
+ parameters: z.object({
94
+ period: z.string().optional().describe("Optional OKR period in YYYY-QN format"),
95
+ }),
96
+ handler: async (args) => await getMyOkrsSummary({
97
+ createTeamPushClient,
98
+ getCurrentUser,
99
+ period: args.period,
100
+ }),
101
+ }),
102
+ defineTool("write_team_wiki", {
103
+ description: "Write or update a page in the shared team wiki",
104
+ parameters: z.object({
105
+ path: z.string().min(1).describe("Shared team wiki page path, starting with pages/shared/"),
106
+ content: z.string().describe("Full markdown content to write to the shared team wiki page"),
107
+ }),
108
+ handler: async (args) => {
109
+ if (!isSharedTeamWikiPath(args.path)) {
110
+ return 'Shared team wiki path must start with "pages/shared/".';
111
+ }
112
+ await createTeamPushClient().writePage(args.path, args.content);
113
+ return `Wrote shared team wiki page: ${args.path}`;
114
+ },
115
+ }),
116
+ defineTool("ado_get_okrs", {
117
+ description: "Get current OKR status from Azure DevOps for a given period (e.g. '2026-Q2')",
118
+ parameters: z.object({
119
+ period: z.string().optional().describe("Optional OKR period such as '2026-Q2'"),
120
+ }),
121
+ handler: async (args) => {
122
+ if (!hasAdoPat()) {
123
+ return "Azure DevOps OKR integration is not configured. Set ADO_PAT in ~/.chapterhouse/.env.";
124
+ }
125
+ return await adoGetOkrs(args.period);
126
+ },
127
+ }),
128
+ defineTool("ado_update_kr", {
129
+ description: "Update the current value of a Key Result in Azure DevOps",
130
+ parameters: z.object({
131
+ workItemId: z.number().int().positive().describe("Azure DevOps work item ID for the key result"),
132
+ currentValue: z.number().finite().describe("New current value for the key result"),
133
+ notes: z.string().optional().describe("Optional progress note to add as an ADO comment"),
134
+ }),
135
+ handler: async (args) => {
136
+ if (!hasAdoPat()) {
137
+ return "Azure DevOps OKR integration is not configured. Set ADO_PAT in ~/.chapterhouse/.env.";
138
+ }
139
+ return await adoUpdateKr(args.workItemId, args.currentValue, args.notes);
140
+ },
141
+ }),
142
+ defineTool("ado_okr_summary", {
143
+ description: "Get a full OKR summary including percent complete for all objectives",
144
+ parameters: z.object({
145
+ period: z.string().optional().describe("Optional OKR period such as '2026-Q2'"),
146
+ }),
147
+ handler: async (args) => {
148
+ if (!hasAdoPat()) {
149
+ return { error: "Azure DevOps OKR integration is not configured. Set ADO_PAT in ~/.chapterhouse/.env." };
150
+ }
151
+ return await adoOkrSummary(args.period);
152
+ },
153
+ }),
154
+ ...(hasAdoPat() ? [
155
+ defineTool("generate_okr_report", {
156
+ description: "Generate a monthly OKR report narrative for the team. Queries ADO for current KR values and drafts an executive summary.",
157
+ parameters: z.object({
158
+ period: z.string().optional().describe("Optional OKR period such as '2026-Q2'"),
159
+ }),
160
+ handler: async (args) => {
161
+ const generator = deps.createReportGenerator?.() ?? await (async () => {
162
+ const { ReportGenerator } = await import("../../integrations/report-generator.js");
163
+ return new ReportGenerator();
164
+ })();
165
+ return await generator.generateMonthlyReport(args.period?.trim() || getCurrentQuarter());
166
+ },
167
+ }),
168
+ ] : []),
169
+ ];
170
+ }
171
+ //# sourceMappingURL=okr.js.map
@@ -0,0 +1,333 @@
1
+ import { defineTool } from "@github/copilot-sdk";
2
+ import { z } from "zod";
3
+ import { appendLog } from "../../wiki/log-manager.js";
4
+ import { appendTimeline } from "../../wiki/timeline.js";
5
+ import { assertPagePath, ensureWikiStructure, writePage } from "../../wiki/fs.js";
6
+ import { addToIndex, buildIndexEntryForPage, reindexWikiPages, searchIndex, } from "../../wiki/index-manager.js";
7
+ import { ingestSource, detectSourceType, looksLikeLocalFilePath } from "../../wiki/ingest.js";
8
+ import { traverse as wikiTraverse } from "../../wiki/links.js";
9
+ import { withWikiWrite } from "../../wiki/lock.js";
10
+ import { loadTaxonomy } from "../../wiki/taxonomy.js";
11
+ import { readWikiPage } from "../../wiki/team-sync.js";
12
+ import { validateWikiFrontmatter, validateAndBackfillFrontmatter } from "../../wiki/frontmatter.js";
13
+ import { childLogger } from "../../util/logger.js";
14
+ const log = childLogger("tools");
15
+ /** Sanitize a single line for safe inclusion as an index/log table entry. */
16
+ function indexSafe(text) {
17
+ return text.replace(/[\r\n|]/g, " ").trim();
18
+ }
19
+ function sanitizeWikiUpdateError(err) {
20
+ if (err instanceof z.ZodError) {
21
+ return err.issues.map((issue) => issue.message).join("; ") || "Invalid wiki_update arguments.";
22
+ }
23
+ const message = err instanceof Error ? err.message : String(err);
24
+ if (message.startsWith("Wiki page frontmatter violates the required shape:")
25
+ || message.startsWith("Wiki path")
26
+ || message.startsWith("Wiki page paths must end in .md:")
27
+ || message.startsWith("Refused unsafe wiki path:")
28
+ || message.startsWith("Refused: only pages under pages/")
29
+ || message === "Wiki path is required") {
30
+ return message;
31
+ }
32
+ return "Wiki update failed. Check the page path and frontmatter, then try again.";
33
+ }
34
+ function validateWikiPageInput(path, content, allowedTags = loadTaxonomy()) {
35
+ assertPagePath(path);
36
+ const backfilled = validateAndBackfillFrontmatter(path, content);
37
+ const nextContent = backfilled.changed ? backfilled.content : content;
38
+ const validation = validateWikiFrontmatter(nextContent, { allowedTags });
39
+ if (!validation.valid) {
40
+ throw new Error(validation.errors.map((error) => error.message).join("\n\n"));
41
+ }
42
+ return nextContent;
43
+ }
44
+ function writeWikiPageAndRefreshIndex(page, logSource) {
45
+ writePage(page.path, page.content);
46
+ const today = new Date().toISOString().slice(0, 10);
47
+ const rebuilt = buildIndexEntryForPage(page.path, {
48
+ section: page.section || "Knowledge",
49
+ updated: today,
50
+ });
51
+ if (rebuilt) {
52
+ rebuilt.section = page.section || "Knowledge";
53
+ addToIndex(rebuilt);
54
+ }
55
+ else {
56
+ addToIndex({
57
+ path: page.path,
58
+ title: page.title,
59
+ summary: indexSafe(page.summary),
60
+ section: page.section || "Knowledge",
61
+ updated: today,
62
+ });
63
+ }
64
+ appendLog("update", `${logSource}: ${indexSafe(page.title)} (${page.path})`);
65
+ }
66
+ const wikiPageArgsSchema = z.object({
67
+ 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')"),
68
+ title: z.string().describe("Page title for the index"),
69
+ summary: z.string().max(160, "Summary must be 160 characters or fewer").describe("One-line summary for the index"),
70
+ section: z.string().optional().describe("Index section (default: 'Knowledge')"),
71
+ content: z.string().describe("Full page content (markdown)"),
72
+ });
73
+ const wikiUpdateArgsSchema = wikiPageArgsSchema;
74
+ const wikiBatchUpdateArgsSchema = z.object({
75
+ pages: z.array(wikiPageArgsSchema)
76
+ .min(1)
77
+ .max(50)
78
+ .describe("Array of pages to create or update (1–50 items)"),
79
+ });
80
+ export function createWikiTools(_deps) {
81
+ return [
82
+ defineTool("remember", {
83
+ description: "REMOVED: Use wiki_update or memory_remember instead.",
84
+ parameters: z.object({}).passthrough(),
85
+ handler: async () => "This tool has been removed. Use wiki_update to write to wiki pages, or memory_remember for agent memory.",
86
+ }),
87
+ defineTool("recall", {
88
+ description: "REMOVED: Use wiki_search or memory_recall instead.",
89
+ parameters: z.object({}).passthrough(),
90
+ handler: async () => "This tool has been removed. Use wiki_search to search wiki pages, or memory_recall for agent memory.",
91
+ }),
92
+ defineTool("forget", {
93
+ description: "REMOVED: Use wiki_update instead.",
94
+ parameters: z.object({}).passthrough(),
95
+ handler: async () => "This tool has been removed. Use wiki_update to modify wiki pages.",
96
+ }),
97
+ // ----- New wiki tools -----
98
+ defineTool("wiki_search", {
99
+ description: "Search Chapterhouse's wiki knowledge base. Returns matching page titles, paths, and summaries " +
100
+ "from the wiki index. Use this to find relevant knowledge before answering questions.",
101
+ parameters: z.object({
102
+ query: z.string().describe("What to search for in the wiki"),
103
+ }),
104
+ handler: async (args) => {
105
+ ensureWikiStructure();
106
+ const matches = searchIndex(args.query, 10);
107
+ if (matches.length === 0)
108
+ return "No matching wiki pages found.";
109
+ const lines = matches.map((m) => `• [${m.title}](${m.path}) — ${m.summary}`);
110
+ return `Found ${matches.length} page(s):\n${lines.join("\n")}`;
111
+ },
112
+ }),
113
+ defineTool("wiki_read", {
114
+ description: "Read a specific wiki page by path. Use after wiki_search to read full page content. " +
115
+ "Paths are relative to the wiki root. Layout: entity categories (projects, people, orgs, tools, " +
116
+ "topics, areas) live at 'pages/<category>/<topic>/index.md' (the topic overview) plus optional " +
117
+ "'pages/<category>/<topic>/<facet>.md' sub-pages; flat categories at 'pages/<category>.md' " +
118
+ "(preferences, facts, routines, decisions); daily summaries at 'pages/conversations/YYYY-MM-DD.md'.",
119
+ parameters: z.object({
120
+ path: z.string().describe("Path to the wiki page (e.g. 'pages/people/brian/index.md', 'pages/projects/chapterhouse/decisions.md', 'index.md')"),
121
+ }),
122
+ handler: async (args) => {
123
+ ensureWikiStructure();
124
+ const content = await readWikiPage(args.path);
125
+ if (!content)
126
+ return `Page not found: ${args.path}`;
127
+ return content;
128
+ },
129
+ }),
130
+ defineTool("wiki_update", {
131
+ description: "Create or update a wiki page. You provide the full page content (markdown with optional " +
132
+ "YAML frontmatter). The page will be written to disk and the index updated. Use this for " +
133
+ "rich knowledge pages, entity pages, synthesis documents — anything more structured than " +
134
+ "a quick 'remember' call. After creating/updating a page, the index is automatically updated. " +
135
+ "PATH RULES: entity-category pages MUST be 'pages/<category>/<topic-slug>/<page>.md' where " +
136
+ "category is one of projects, people, orgs, tools, topics, areas, '<page>' is 'index' for the " +
137
+ "topic overview or a facet name (e.g. 'decisions', 'feature-ideas') — exactly one topic level, " +
138
+ "lowercase slugs only. Flat-category pages MUST be 'pages/<category>.md' (preferences, facts, " +
139
+ "routines, decisions). Bad paths are rejected with a suggested correction.",
140
+ parameters: wikiUpdateArgsSchema,
141
+ handler: async (args) => {
142
+ try {
143
+ const parsedArgs = wikiUpdateArgsSchema.parse(args);
144
+ ensureWikiStructure();
145
+ return await withWikiWrite(async () => {
146
+ const content = validateWikiPageInput(parsedArgs.path, parsedArgs.content);
147
+ const page = { ...parsedArgs, content };
148
+ writeWikiPageAndRefreshIndex(page, "wiki_update");
149
+ return `Wiki page updated: ${page.title} (${page.path})`;
150
+ });
151
+ }
152
+ catch (err) {
153
+ const error = sanitizeWikiUpdateError(err);
154
+ log.error({ err: err instanceof Error ? err.message : err, path: typeof args?.path === "string" ? args.path : undefined }, "wiki_update failed");
155
+ return { error };
156
+ }
157
+ },
158
+ }),
159
+ defineTool("wiki_batch_update", {
160
+ description: "Create or update multiple wiki pages in a single operation. " +
161
+ "Each page follows the same path rules as wiki_update. " +
162
+ "Up to 50 pages per call. Returns a per-page success/error summary.",
163
+ parameters: wikiBatchUpdateArgsSchema,
164
+ handler: async (args) => {
165
+ try {
166
+ const parsedArgs = wikiBatchUpdateArgsSchema.parse(args);
167
+ ensureWikiStructure();
168
+ return await withWikiWrite(async () => {
169
+ const allowedTags = loadTaxonomy();
170
+ const results = [];
171
+ for (const pageArgs of parsedArgs.pages) {
172
+ try {
173
+ const content = validateWikiPageInput(pageArgs.path, pageArgs.content, allowedTags);
174
+ writeWikiPageAndRefreshIndex({ ...pageArgs, content }, "wiki_batch_update");
175
+ results.push({ path: pageArgs.path, status: "ok" });
176
+ }
177
+ catch (err) {
178
+ results.push({
179
+ path: pageArgs.path,
180
+ status: "error",
181
+ error: sanitizeWikiUpdateError(err),
182
+ });
183
+ }
184
+ }
185
+ const createdCount = results.filter((result) => result.status === "ok").length;
186
+ const errors = results.filter((result) => result.status === "error");
187
+ if (errors.length === 0) {
188
+ return `Created ${createdCount} pages successfully.`;
189
+ }
190
+ return `Created ${createdCount} pages successfully.\nErrors (${errors.length}):\n${errors.map((result) => ` • ${result.path} — ${result.error}`).join("\n")}`;
191
+ });
192
+ }
193
+ catch (err) {
194
+ const error = sanitizeWikiUpdateError(err);
195
+ log.error({ err: err instanceof Error ? err.message : err }, "wiki_batch_update failed");
196
+ return { error };
197
+ }
198
+ },
199
+ }),
200
+ defineTool("wiki_ingest", {
201
+ description: "REMOVED: Use wiki_ingest_source instead.",
202
+ parameters: z.object({}).passthrough(),
203
+ handler: async () => "This tool has been removed. Use wiki_ingest_source instead.",
204
+ }),
205
+ defineTool("wiki_lint", {
206
+ description: "REMOVED: Wiki health checks are no longer needed.",
207
+ parameters: z.object({}).passthrough(),
208
+ handler: async () => "This tool has been removed. Wiki health checks are no longer needed with SQLite-backed storage.",
209
+ }),
210
+ defineTool("wiki_rebuild_index", {
211
+ description: "REMOVED: The wiki index is maintained automatically.",
212
+ parameters: z.object({}).passthrough(),
213
+ handler: async () => "This tool has been removed. The wiki index is now maintained automatically via SQLite FTS5.",
214
+ }),
215
+ defineTool("wiki_reindex", {
216
+ description: "Force a full wiki filesystem-to-SQLite reindex pass.",
217
+ parameters: z.object({}),
218
+ handler: async () => {
219
+ ensureWikiStructure();
220
+ const result = reindexWikiPages();
221
+ appendLog("update", `wiki_reindex: rebuilt ${result.indexedPageCount} page(s)`);
222
+ return `Reindexed ${result.indexedPageCount} wiki page(s) from disk.`;
223
+ },
224
+ }),
225
+ defineTool("wiki_traverse", {
226
+ description: "Walk the wiki entity graph from a starting page. Returns pages connected by typed links. " +
227
+ "Use to discover related knowledge, trace dependencies, find who works on a project, etc. " +
228
+ "Depth 1 returns direct neighbors; depth 2-3 expands further (max 3). " +
229
+ "Optionally filter by link_type: references, implements, supersedes, member_of, works_on, decided_by, depends_on, attended, follow_up.",
230
+ parameters: z.object({
231
+ page: z.string().describe("Wiki page path to traverse from (e.g. 'pages/topics/rust/index.md')"),
232
+ link_type: z.string().optional().describe("Filter by link type (e.g. 'references', 'implements')"),
233
+ depth: z.number().int().min(1).max(3).optional().describe("Traversal depth (1–3, default 1)"),
234
+ }),
235
+ handler: async (args) => {
236
+ ensureWikiStructure();
237
+ const results = wikiTraverse(args.page, args.link_type, args.depth ?? 1);
238
+ if (results.length === 0)
239
+ return `No linked pages found for: ${args.page}`;
240
+ const lines = results.map((r) => `• [depth ${r.depth}] ${r.direction === "outbound" ? "→" : "←"} ${r.page} (${r.link_type})`);
241
+ return `Found ${results.length} linked page(s):\n${lines.join("\n")}`;
242
+ },
243
+ }),
244
+ defineTool("wiki_fix", {
245
+ description: "REMOVED: Use wiki_update instead.",
246
+ parameters: z.object({}).passthrough(),
247
+ handler: async () => "This tool has been removed. Use wiki_update to correct wiki pages.",
248
+ }),
249
+ defineTool("wiki_append_timeline", {
250
+ description: "Append an entry to the '## Timeline' section of a wiki page. " +
251
+ "Creates the section (and the page itself) if absent. " +
252
+ "Timeline is append-only — existing entries are never modified. " +
253
+ "Use for recording events, source ingestion, and interaction history.",
254
+ parameters: z.object({
255
+ page: z.string().describe("Relative path from wiki root (e.g. 'pages/people/alice/index.md')"),
256
+ entry: z.string().describe("Markdown text for the timeline entry"),
257
+ source_id: z.string().optional().describe("Optional reference to a wiki_sources id"),
258
+ }),
259
+ handler: async (args) => {
260
+ ensureWikiStructure();
261
+ try {
262
+ const entry = args.source_id
263
+ ? `${args.entry}\n\n_Source: ${args.source_id}_`
264
+ : args.entry;
265
+ return await withWikiWrite(async () => {
266
+ appendTimeline(args.page, entry);
267
+ return `Timeline entry appended to ${args.page}`;
268
+ });
269
+ }
270
+ catch (err) {
271
+ log.error({ err: err instanceof Error ? err.message : err, page: args.page }, "wiki_append_timeline failed");
272
+ return { error: err instanceof Error ? err.message : "wiki_append_timeline failed" };
273
+ }
274
+ },
275
+ }),
276
+ defineTool("wiki_ingest_source", {
277
+ description: "Ingest an external source (URL, PDF, Git repo, or raw text) into the PKB. " +
278
+ "Fetches and parses the content, extracts entities with LLM, creates/updates wiki pages, " +
279
+ "and writes timeline entries. Idempotent: re-ingesting the same source returns the existing result. " +
280
+ "Type is auto-detected if omitted.",
281
+ parameters: z.object({
282
+ source: z.string().describe("URL, file path, git repo URL, or raw text content to ingest"),
283
+ type: z.enum(["url", "pdf", "repo", "text"]).optional().describe("Source type. Auto-detected if omitted: http(s) URL → url/repo, .pdf path → pdf, else text"),
284
+ topic: z.string().optional().describe("Optional hint for entity extraction focus"),
285
+ session_id: z.string().optional().describe("Optional research session id to persist in wiki_sources"),
286
+ session_name: z.string().optional().describe("Optional human-readable research session name"),
287
+ }),
288
+ handler: async (args) => {
289
+ ensureWikiStructure();
290
+ try {
291
+ if (looksLikeLocalFilePath(args.source)) {
292
+ return {
293
+ error: "wiki_ingest_source does not support local file paths. Provide a URL, git repo URL, or raw text content.",
294
+ };
295
+ }
296
+ const sourceType = args.type ?? detectSourceType(args.source);
297
+ const result = await ingestSource(args.source, sourceType, args.topic, {
298
+ sessionId: args.session_id,
299
+ sessionName: args.session_name,
300
+ });
301
+ if (result.already_existed) {
302
+ return (`Source already ingested (id: ${result.source_id}).\n` +
303
+ `Pages previously updated: ${result.pages_updated.length > 0 ? result.pages_updated.join(", ") : "none"}`);
304
+ }
305
+ const lines = [
306
+ `✅ Ingested source (id: ${result.source_id})`,
307
+ ];
308
+ if (result.pages_created.length > 0) {
309
+ lines.push(`📄 Pages created (${result.pages_created.length}): ${result.pages_created.join(", ")}`);
310
+ }
311
+ if (result.pages_updated.length > 0) {
312
+ lines.push(`✏️ Pages updated (${result.pages_updated.length}): ${result.pages_updated.join(", ")}`);
313
+ }
314
+ if (result.entities.length > 0) {
315
+ lines.push(`🔍 Entities extracted (${result.entities.length}):`);
316
+ for (const e of result.entities) {
317
+ lines.push(` • ${e.name} (${e.type}) → ${e.path}`);
318
+ }
319
+ }
320
+ else {
321
+ lines.push("🔍 No entities extracted.");
322
+ }
323
+ return lines.join("\n");
324
+ }
325
+ catch (err) {
326
+ log.error({ err: err instanceof Error ? err.message : err }, "wiki_ingest_source failed");
327
+ return { error: err instanceof Error ? err.message : "Ingestion failed" };
328
+ }
329
+ },
330
+ }),
331
+ ];
332
+ }
333
+ //# sourceMappingURL=wiki.js.map
@@ -0,0 +1,4 @@
1
+ export * from "./agents.js";
2
+ export * as agentsModule from "./agents.js";
3
+ export { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentSessionKey, sendToAgentSession, switchSessionModel, invalidateOrchestratorSession, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, } from "./orchestrator.js";
4
+ //# sourceMappingURL=tools-deps.js.map
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import test from "node:test";
6
+ let activeMockState;
6
7
  function createActiveProjectRules() {
7
8
  return {
8
9
  project: {
@@ -66,29 +67,30 @@ async function loadToolsModule(t, options) {
66
67
  const sentPrompts = [];
67
68
  const persistentSends = [];
68
69
  const taskId = options?.taskId ?? `delegated-task-${Date.now()}-${Math.random()}`;
70
+ activeMockState = { options, sentPrompts, persistentSends, taskId };
69
71
  const fakeSession = {
70
72
  on: () => () => { },
71
73
  async sendAndWait(request) {
72
- sentPrompts.push(request.prompt);
74
+ activeMockState.sentPrompts.push(request.prompt);
73
75
  return { data: { content: `handled: ${request.prompt}` } };
74
76
  },
75
77
  async destroy() { },
76
78
  };
77
79
  t.mock.module("./orchestrator.js", {
78
80
  namedExports: {
79
- getCurrentSourceChannel: () => options?.sourceChannel ?? "web",
81
+ getCurrentSourceChannel: () => activeMockState.options?.sourceChannel ?? "web",
80
82
  getCurrentActivityCallback: () => undefined,
81
83
  getCurrentAuthenticatedUser: () => undefined,
82
84
  getLastAuthenticatedUser: () => undefined,
83
85
  getCurrentAuthorizationHeader: () => undefined,
84
86
  getCurrentSessionKey: () => "session-test",
85
- getCurrentActiveProjectRules: () => options?.activeProjectRules ?? null,
87
+ getCurrentActiveProjectRules: () => activeMockState.options?.activeProjectRules ?? null,
86
88
  maybeScheduleScopeChangeCheckpoint: () => { },
87
89
  invalidateOrchestratorSession: () => { },
88
90
  resetCheckpointSessionState: () => { },
89
91
  switchSessionModel: async () => { },
90
92
  sendToAgentSession: async (slug, prompt, delegatedTaskId) => {
91
- persistentSends.push({ slug, prompt, taskId: delegatedTaskId });
93
+ activeMockState.persistentSends.push({ slug, prompt, taskId: delegatedTaskId });
92
94
  return `persistent handled: ${prompt}`;
93
95
  },
94
96
  },
@@ -105,7 +107,7 @@ async function loadToolsModule(t, options) {
105
107
  ? { slug: "bellonda", name: "Bellonda", model: "claude-sonnet-4.6", persistent: true, scope: "infra" }
106
108
  : undefined,
107
109
  createEphemeralAgentSession: async () => {
108
- if (options?.persistentAgent) {
110
+ if (activeMockState.options?.persistentAgent) {
109
111
  throw new Error("persistent agent should not use ephemeral session");
110
112
  }
111
113
  return fakeSession;
@@ -113,14 +115,14 @@ async function loadToolsModule(t, options) {
113
115
  getAgentSessionStatus: () => ({ tasks: [] }),
114
116
  getActiveTasks: () => [],
115
117
  getTask: () => undefined,
116
- createTaskId: () => taskId,
118
+ createTaskId: () => activeMockState.taskId,
117
119
  registerTask: () => ({
118
- taskId,
120
+ taskId: activeMockState.taskId,
119
121
  agentSlug: "coder",
120
122
  description: "Show dispatched prompt",
121
123
  status: "running",
122
124
  startedAt: Date.now(),
123
- originChannel: options?.sourceChannel ?? "web",
125
+ originChannel: activeMockState.options?.sourceChannel ?? "web",
124
126
  }),
125
127
  completeTask: () => { },
126
128
  failTask: () => { },
@@ -0,0 +1,76 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ async function loadToolsIndex() {
4
+ process.env.ADO_PAT = "inventory-test-pat";
5
+ return await import(new URL(`./tools/index.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
6
+ }
7
+ test("createTools returns the full expected tool set without duplicate names", async () => {
8
+ const toolsModule = await loadToolsIndex();
9
+ const tools = toolsModule.createTools({
10
+ client: { async listModels() { return []; } },
11
+ onAgentTaskComplete: () => { },
12
+ createReportGenerator: () => ({
13
+ async generateMonthlyReport() {
14
+ return "inventory test report";
15
+ },
16
+ }),
17
+ });
18
+ const names = tools.map((tool) => tool.name);
19
+ const expectedNames = [
20
+ "delegate_to_agent",
21
+ "check_agent_status",
22
+ "get_agent_result",
23
+ "show_agent_roster",
24
+ "teams_notify",
25
+ "log_okr_progress",
26
+ "get_my_okrs",
27
+ "write_team_wiki",
28
+ "ado_get_okrs",
29
+ "ado_update_kr",
30
+ "ado_okr_summary",
31
+ "generate_okr_report",
32
+ "hire_agent",
33
+ "fire_agent",
34
+ "list_machine_sessions",
35
+ "attach_machine_session",
36
+ "list_skills",
37
+ "learn_skill",
38
+ "uninstall_skill",
39
+ "list_models",
40
+ "switch_model",
41
+ "toggle_auto",
42
+ "memory_remember",
43
+ "memory_propose",
44
+ "memory_create_scope",
45
+ "memory_add_action_item",
46
+ "memory_complete_action_item",
47
+ "memory_drop_action_item",
48
+ "memory_snooze_action_item",
49
+ "memory_list_action_items",
50
+ "memory_recall",
51
+ "memory_housekeep",
52
+ "memory_reflect",
53
+ "memory_promote",
54
+ "memory_demote",
55
+ "memory_set_scope",
56
+ "remember",
57
+ "recall",
58
+ "forget",
59
+ "wiki_search",
60
+ "wiki_read",
61
+ "wiki_update",
62
+ "wiki_batch_update",
63
+ "wiki_ingest",
64
+ "wiki_lint",
65
+ "wiki_rebuild_index",
66
+ "wiki_reindex",
67
+ "wiki_traverse",
68
+ "wiki_fix",
69
+ "wiki_append_timeline",
70
+ "wiki_ingest_source",
71
+ "restart_chapterhouse",
72
+ ];
73
+ assert.deepEqual([...names].sort(), [...expectedNames].sort());
74
+ assert.equal(new Set(names).size, names.length);
75
+ });
76
+ //# sourceMappingURL=tools.inventory.test.js.map