chapterhouse 0.5.0 → 0.5.2

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 (40) hide show
  1. package/dist/api/server.js +31 -3
  2. package/dist/api/server.test.js +48 -5
  3. package/dist/cli.js +4 -2
  4. package/dist/config.js +75 -13
  5. package/dist/config.test.js +44 -0
  6. package/dist/copilot/orchestrator.js +17 -6
  7. package/dist/copilot/orchestrator.test.js +50 -3
  8. package/dist/copilot/router.js +43 -8
  9. package/dist/copilot/router.test.js +30 -18
  10. package/dist/copilot/system-message.js +3 -3
  11. package/dist/copilot/system-message.test.js +10 -0
  12. package/dist/copilot/tools.js +79 -12
  13. package/dist/copilot/tools.memory.test.js +94 -6
  14. package/dist/daemon.js +7 -2
  15. package/dist/integrations/team-push.js +8 -1
  16. package/dist/integrations/teams-notify.js +8 -1
  17. package/dist/memory/active-scope.test.js +7 -2
  18. package/dist/memory/eot.js +96 -8
  19. package/dist/memory/eot.test.js +186 -5
  20. package/dist/memory/hot-tier.test.js +14 -4
  21. package/dist/memory/housekeeping.js +73 -25
  22. package/dist/memory/housekeeping.test.js +115 -16
  23. package/dist/memory/inbox.test.js +178 -0
  24. package/dist/memory/scopes.test.js +0 -24
  25. package/dist/memory/tiering.test.js +323 -0
  26. package/dist/mode-context.js +28 -0
  27. package/dist/mode-context.test.js +42 -0
  28. package/dist/setup.js +152 -95
  29. package/dist/setup.test.js +122 -0
  30. package/dist/store/db.js +0 -18
  31. package/dist/wiki/team-sync.js +8 -1
  32. package/package.json +1 -1
  33. package/web/dist/assets/{index-BfHqP3-C.js → index-CPaILy2j.js} +69 -69
  34. package/web/dist/assets/index-CPaILy2j.js.map +1 -0
  35. package/web/dist/assets/index-Cs7AGeaL.css +10 -0
  36. package/web/dist/index.html +2 -2
  37. package/agents/bellonda.agent.md +0 -11
  38. package/agents/hwi-noree.agent.md +0 -12
  39. package/web/dist/assets/index-BfHqP3-C.js.map +0 -1
  40. package/web/dist/assets/index-_O6AoWOS.css +0 -10
@@ -13,6 +13,13 @@ async function loadRouterModule(t, options = {}) {
13
13
  },
14
14
  },
15
15
  });
16
+ t.mock.module("../config.js", {
17
+ namedExports: {
18
+ config: {
19
+ chapterhouseMode: options.mode ?? "personal",
20
+ },
21
+ },
22
+ });
16
23
  t.mock.module("./classifier.js", {
17
24
  namedExports: {
18
25
  classifyWithLLM: async (_client, prompt) => await (options.classify?.(prompt) ?? null),
@@ -21,16 +28,14 @@ async function loadRouterModule(t, options = {}) {
21
28
  const router = await import(new URL(`./router.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
22
29
  return { router, state };
23
30
  }
24
- test("router config falls back safely and deep-merges tier model updates", async (t) => {
25
- const { router, state } = await loadRouterModule(t, {
26
- storedConfig: "{not-json}",
27
- });
31
+ test("router config defaults personal-mode auto-routing to standard-cost routes before opt-in", async (t) => {
32
+ const { router } = await loadRouterModule(t, { mode: "personal" });
28
33
  assert.deepEqual(router.getRouterConfig(), {
29
- enabled: false,
34
+ enabled: true,
30
35
  tierModels: {
31
36
  fast: "gpt-4.1",
32
37
  standard: "claude-sonnet-4.6",
33
- premium: "claude-opus-4.6",
38
+ premium: "claude-sonnet-4.6",
34
39
  },
35
40
  overrides: [
36
41
  {
@@ -39,19 +44,28 @@ test("router config falls back safely and deep-merges tier model updates", async
39
44
  "design", "ui", "ux", "css", "layout", "styling", "visual",
40
45
  "mockup", "wireframe", "frontend design", "tailwind", "responsive",
41
46
  ],
42
- model: "claude-opus-4.6",
47
+ model: "claude-sonnet-4.6",
43
48
  },
44
49
  ],
45
50
  cooldownMessages: 2,
46
51
  });
52
+ });
53
+ test("router config keeps auto-routing off by default in team mode", async (t) => {
54
+ const { router } = await loadRouterModule(t, { mode: "team" });
55
+ assert.equal(router.getRouterConfig().enabled, false);
56
+ });
57
+ test("saving router config in personal mode opts into premium defaults and deep-merges tier model updates", async (t) => {
58
+ const { router, state } = await loadRouterModule(t, { mode: "personal" });
59
+ const saved = router.updateRouterConfig({ enabled: true });
60
+ assert.equal(saved.enabled, true);
61
+ assert.equal(saved.tierModels.premium, "claude-opus-4.6");
62
+ assert.equal(saved.overrides[0]?.model, "claude-opus-4.6");
47
63
  const updated = router.updateRouterConfig({
48
- enabled: true,
49
64
  tierModels: {
50
- ...router.getRouterConfig().tierModels,
65
+ ...saved.tierModels,
51
66
  premium: "gpt-5.5",
52
67
  },
53
68
  });
54
- assert.equal(updated.enabled, true);
55
69
  assert.deepEqual(updated.tierModels, {
56
70
  fast: "gpt-4.1",
57
71
  standard: "claude-sonnet-4.6",
@@ -60,7 +74,7 @@ test("router config falls back safely and deep-merges tier model updates", async
60
74
  assert.equal(JSON.parse(state.get("router_config") || "{}").tierModels.premium, "gpt-5.5");
61
75
  });
62
76
  test("resolveModel stays in manual mode when the router is disabled", async (t) => {
63
- const { router } = await loadRouterModule(t);
77
+ const { router } = await loadRouterModule(t, { mode: "team" });
64
78
  const result = await router.resolveModel("Ship it", "claude-sonnet-4.6", []);
65
79
  assert.deepEqual(result, {
66
80
  model: "claude-sonnet-4.6",
@@ -69,28 +83,26 @@ test("resolveModel stays in manual mode when the router is disabled", async (t)
69
83
  routerMode: "manual",
70
84
  });
71
85
  });
72
- test("resolveModel applies overrides by whole word and ignores partial-word matches", async (t) => {
86
+ test("resolveModel applies safe design overrides before personal-mode opt-in and ignores partial-word matches", async (t) => {
73
87
  const { router } = await loadRouterModule(t, {
74
88
  classify: async () => "fast",
75
89
  });
76
- router.updateRouterConfig({ enabled: true });
77
90
  const override = await router.resolveModel("Need a UI refresh", "gpt-4.1", [], {});
78
91
  const noOverride = await router.resolveModel("Fruit salad", "gpt-4.1", [], {});
79
92
  assert.equal(override.overrideName, "design");
80
- assert.equal(override.model, "claude-opus-4.6");
93
+ assert.equal(override.model, "claude-sonnet-4.6");
81
94
  assert.equal(override.switched, true);
82
95
  assert.equal(noOverride.overrideName, undefined);
83
96
  assert.equal(noOverride.model, "gpt-4.1");
84
97
  assert.equal(noOverride.tier, "fast");
85
98
  });
86
- test("short follow-ups inherit the previous tier", async (t) => {
99
+ test("short follow-ups inherit the previous tier without forcing premium before opt-in", async (t) => {
87
100
  const { router } = await loadRouterModule(t);
88
- router.updateRouterConfig({ enabled: true });
89
101
  const result = await router.resolveModel("yes", "claude-sonnet-4.6", ["premium"]);
90
102
  assert.deepEqual(result, {
91
- model: "claude-opus-4.6",
103
+ model: "claude-sonnet-4.6",
92
104
  tier: "premium",
93
- switched: true,
105
+ switched: false,
94
106
  routerMode: "auto",
95
107
  });
96
108
  });
@@ -39,7 +39,7 @@ ${hotTierBlock}
39
39
  You are a Node.js daemon process built with the Copilot SDK. Here's how you work:
40
40
 
41
41
  - **Web UI**: Your primary interface. The team talks to you in a browser tab at http://localhost:7788. Messages arrive tagged with \`[via web]\`. Markdown rendering and code blocks are fully supported, so feel free to be detailed when it helps — but stay focused.
42
- - **Background tasks**: Messages tagged \`[via background]\` are results from agent tasks you delegated. Summarize and relay these to the team.
42
+ - **Background tasks**: Messages tagged \`[via background]\` are results from agent tasks you delegated or system follow-ups. Only summarize background content that has not already been rendered in a separate agent bubble.
43
43
 
44
44
  When no source tag is present, assume web.
45
45
 
@@ -68,7 +68,7 @@ The \`delegate_to_agent\` tool is **non-blocking**. It dispatches the task and r
68
68
  1. When you delegate a task, acknowledge it right away. Be natural and brief: "On it — I've asked @coder to handle that." or "Sending this to @designer."
69
69
  2. You do NOT wait for the agent to finish. The tool returns immediately.
70
70
  3. After delegating, do NOT poll \`get_agent_result\` in a loop. Wait silently for the \`[Agent task completed]\` message to arrive automatically.
71
- 4. When that completion message arrives, call \`get_agent_result\` exactly once for that task, then summarize the result and relay it to the user in a clear, concise way.
71
+ 4. When that completion message arrives, call \`get_agent_result\` exactly once for that task, then follow the subagent completion rule below.
72
72
 
73
73
  You can delegate **multiple tasks simultaneously**. Different agents can work in parallel.
74
74
 
@@ -146,7 +146,7 @@ Subagent proposals from \`memory_propose\` are processed automatically at end-of
146
146
  2. **Skill-first mindset**: Search skills.sh for existing skills before building from scratch.
147
147
  3. For execution tasks, **always** delegate to a specialist agent. You cannot write code, run commands, or read files directly.
148
148
  4. **Announce your delegations**: Tell the user which agent you're sending work to and what the task is.
149
- 5. When you receive background results, summarize the key points. Don't relay the entire output verbatim.
149
+ 5. **Subagent completion rule**: Subagent replies are already shown to the user in their own bubble. When you receive \`[Agent task completed]\`, your follow-up should be a brief acknowledgment unless you have non-obvious framing or next-step decisions to add. Do NOT restate, paraphrase, or summarize the agent's content. Do NOT re-list files changed, re-state merge SHAs, re-quote the agent's bullet points, or copy the agent's table verbatim.
150
150
  6. If asked about status, check agent status and give a consolidated update.
151
151
  7. You can delegate to multiple agents simultaneously — use this for parallel work.
152
152
  8. When a task is complete, relay the results clearly.
@@ -43,6 +43,16 @@ test("orchestrator prompt tells Chapterhouse to wait for agent completion notifi
43
43
  assert.match(message, /wait silently for the `\[Agent task completed\]` message/i);
44
44
  assert.match(message, /call `get_agent_result` exactly once/i);
45
45
  });
46
+ test("orchestrator prompt tells Chapterhouse not to restate already-rendered subagent replies", () => {
47
+ const message = getOrchestratorSystemMessage();
48
+ assert.match(message, /Subagent replies are already shown to the user in their own bubble/i);
49
+ assert.match(message, /brief acknowledgment/i);
50
+ assert.match(message, /Do NOT restate, paraphrase, or summarize the agent's content/i);
51
+ assert.match(message, /re-list files changed/i);
52
+ assert.match(message, /re-state merge SHAs/i);
53
+ assert.doesNotMatch(message, /When you receive background results, summarize the key points/i);
54
+ assert.doesNotMatch(message, /summarize the result and relay it to the user/i);
55
+ });
46
56
  test("orchestrator prompt expands shorthand paths with the current home directory", () => {
47
57
  const message = getOrchestratorSystemMessage();
48
58
  assert.match(message, new RegExp(join(homedir(), "dev", "myapp").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
@@ -6,6 +6,7 @@ import { join } from "path";
6
6
  import { homedir } from "os";
7
7
  import { listSkills, createSkill, removeSkill } from "./skills.js";
8
8
  import { config, persistModel } from "../config.js";
9
+ import { ModeContext } from "../mode-context.js";
9
10
  import { agentEventBus } from "./agent-event-bus.js";
10
11
  import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, sendToAgentSession, invalidateOrchestratorSession, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, switchSessionModel, } from "./orchestrator.js";
11
12
  import { getRouterConfig, updateRouterConfig } from "./router.js";
@@ -29,8 +30,9 @@ import { TeamsNotifier } from "../integrations/teams-notify.js";
29
30
  import { TeamPushClient } from "../integrations/team-push.js";
30
31
  import { OKRMapper, parseOKRPageContent } from "./okr-mapper.js";
31
32
  import { childLogger } from "../util/logger.js";
32
- import { getActiveScope as getMemoryActiveScope, getScope as getMemoryScope, inferScopeFromText, completeActionItem, demoteToCold, demoteToWarm, dropActionItem, queueMemoryProposal, recall as recallMemory, listActionItems, recordDecision, recordActionItem, recordObservation, runHousekeeping, setActiveScope as setMemoryActiveScope, snoozeActionItem, promoteToHot, upsertEntity, } from "../memory/index.js";
33
+ import { getActiveScope as getMemoryActiveScope, createScope as createMemoryScope, getScope as getMemoryScope, inferScopeFromText, completeActionItem, demoteToCold, demoteToWarm, dropActionItem, queueMemoryProposal, recall as recallMemory, listActionItems, recordDecision, recordActionItem, recordObservation, runHousekeeping, setActiveScope as setMemoryActiveScope, snoozeActionItem, promoteToHot, upsertEntity, } from "../memory/index.js";
33
34
  const log = childLogger("tools");
35
+ const modeContext = new ModeContext(config);
34
36
  /** Escape a string for safe inclusion as a single-line YAML scalar value. */
35
37
  function yamlEscape(value) {
36
38
  // Always quote and escape backslashes, double quotes, and newlines.
@@ -66,19 +68,19 @@ function requireOrchestratorMemoryWrite() {
66
68
  return null;
67
69
  }
68
70
  function resolveProposalScopeSlug(requestedScopeSlug) {
69
- const agentSlug = agentsModule.getCurrentToolAgentSlug?.();
70
- if (!agentSlug || agentSlug === "chapterhouse") {
71
+ if (requestedScopeSlug) {
71
72
  return requestedScopeSlug;
72
73
  }
73
- const activeScope = getMemoryActiveScope();
74
- if (activeScope) {
75
- return activeScope.slug;
74
+ const agentSlug = agentsModule.getCurrentToolAgentSlug?.();
75
+ if (!agentSlug || agentSlug === "chapterhouse") {
76
+ return getMemoryActiveScope()?.slug;
76
77
  }
77
78
  const agent = getAgent(agentSlug);
78
- if (agent?.persistent && agent.scope) {
79
- return agent.scope;
79
+ const boundScope = agent?.scope ?? loadAgents().find((entry) => entry.slug === agentSlug)?.scope;
80
+ if (boundScope && getMemoryScope(boundScope)) {
81
+ return boundScope;
80
82
  }
81
- return requestedScopeSlug;
83
+ return getMemoryActiveScope()?.slug;
82
84
  }
83
85
  function resolveMemoryScopeForWrite(explicitScope, content) {
84
86
  const explicit = explicitScope ? getMemoryScope(explicitScope) : undefined;
@@ -138,11 +140,28 @@ const entityProposalPayloadSchema = z.object({
138
140
  summary: value.summary,
139
141
  }));
140
142
  const actionItemProposalPayloadSchema = z.object({
141
- title: z.string(),
143
+ title: z.string().trim().min(1, "title is required for action_item proposals."),
142
144
  detail: z.string().optional(),
143
145
  due_at: z.string().optional(),
144
146
  source: z.string().optional(),
145
147
  entity_id: z.number().int().positive().optional(),
148
+ entity_name: z.string().trim().min(1, "entity_name must be non-empty when provided.").optional(),
149
+ entity_kind: z.string().trim().min(1, "entity_kind must be non-empty when provided.").optional(),
150
+ }).superRefine((value, context) => {
151
+ if ((value.entity_name && !value.entity_kind) || (!value.entity_name && value.entity_kind)) {
152
+ context.addIssue({
153
+ code: z.ZodIssueCode.custom,
154
+ message: "entity_name and entity_kind must be provided together for action_item proposals.",
155
+ path: ["entity_name"],
156
+ });
157
+ }
158
+ if (value.entity_id !== undefined && value.entity_name) {
159
+ context.addIssue({
160
+ code: z.ZodIssueCode.custom,
161
+ message: "Provide either entity_id or entity_name/entity_kind for action_item proposals, not both.",
162
+ path: ["entity_id"],
163
+ });
164
+ }
146
165
  });
147
166
  const memoryProposeArgsSchema = z.object({
148
167
  kind: z.enum(["observation", "decision", "entity", "action_item"]),
@@ -519,7 +538,7 @@ export function createTools(deps) {
519
538
  notes: z.string().optional().describe("Optional notes about the work"),
520
539
  }),
521
540
  handler: async (args) => {
522
- if (config.chapterhouseMode !== "personal") {
541
+ if (!modeContext.canLogToAdo()) {
523
542
  return "OKR progress logging is only available from personal Chapterhouse instances.";
524
543
  }
525
544
  const mapper = createOKRMapper();
@@ -967,6 +986,54 @@ export function createTools(deps) {
967
986
  }
968
987
  },
969
988
  }),
989
+ defineTool("memory_create_scope", {
990
+ description: "Create a new user-defined memory scope. Slugs must be unique kebab-case; baseline installs only include global and chapterhouse.",
991
+ parameters: z.object({
992
+ slug: z.string()
993
+ .trim()
994
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "slug must be unique kebab-case"),
995
+ title: z.string().trim().min(1, "title is required"),
996
+ description: z.string().optional(),
997
+ }),
998
+ handler: async (args) => {
999
+ const denied = requireOrchestratorMemoryWrite();
1000
+ if (denied)
1001
+ return denied;
1002
+ const parsed = z.object({
1003
+ slug: z.string()
1004
+ .trim()
1005
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "slug must be unique kebab-case"),
1006
+ title: z.string().trim().min(1, "title is required"),
1007
+ description: z.string().optional(),
1008
+ }).safeParse(args);
1009
+ if (!parsed.success) {
1010
+ return parsed.error.issues.map((issue) => issue.message).join("; ");
1011
+ }
1012
+ try {
1013
+ if (getMemoryScope(parsed.data.slug)) {
1014
+ return `Memory scope '${parsed.data.slug}' already exists.`;
1015
+ }
1016
+ const scope = createMemoryScope({
1017
+ slug: parsed.data.slug,
1018
+ title: parsed.data.title,
1019
+ description: parsed.data.description ?? "",
1020
+ keywords: [parsed.data.slug],
1021
+ });
1022
+ return {
1023
+ ok: true,
1024
+ scope: {
1025
+ slug: scope.slug,
1026
+ title: scope.title,
1027
+ description: scope.description,
1028
+ active: scope.active,
1029
+ },
1030
+ };
1031
+ }
1032
+ catch (err) {
1033
+ return err instanceof Error ? err.message : String(err);
1034
+ }
1035
+ },
1036
+ }),
970
1037
  defineTool("memory_add_action_item", {
971
1038
  description: "Add a scoped memory action item/reminder. Orchestrator-only write tool for follow-ups and proactive reminders.",
972
1039
  parameters: z.object({
@@ -1181,7 +1248,7 @@ export function createTools(deps) {
1181
1248
  if (args.scope_slug && !requestedScope) {
1182
1249
  return `Unknown memory scope '${args.scope_slug}'.`;
1183
1250
  }
1184
- const result = runHousekeeping({
1251
+ const result = await runHousekeeping({
1185
1252
  scopeIds: requestedScope ? [requestedScope.id] : undefined,
1186
1253
  allScopes: args.all_scopes,
1187
1254
  passes: args.passes,
@@ -85,6 +85,7 @@ test("memory tools remember, recall, set scope, and enforce orchestrator-only wr
85
85
  const memoryRemember = findTool(tools, "memory_remember");
86
86
  const memoryRecall = findTool(tools, "memory_recall");
87
87
  const memorySetScope = findTool(tools, "memory_set_scope");
88
+ const memoryCreateScope = findTool(tools, "memory_create_scope");
88
89
  const bindToolsToAgent = agentsModule.bindToolsToAgent;
89
90
  assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
90
91
  const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
@@ -121,6 +122,30 @@ test("memory tools remember, recall, set scope, and enforce orchestrator-only wr
121
122
  assert.equal((recalled.hits ?? []).some((hit) => hit.id === row.id), true);
122
123
  const scopeResult = await chapterhouseSetScope.handler({ slug: "chapterhouse" }, {});
123
124
  assert.equal(scopeResult.active_scope?.slug, "chapterhouse");
125
+ const createdScope = await memoryCreateScope.handler({
126
+ slug: "docs-site",
127
+ title: "Docs Site",
128
+ description: "Documentation publishing and content workflows",
129
+ }, {});
130
+ assert.deepEqual(createdScope, {
131
+ ok: true,
132
+ scope: {
133
+ slug: "docs-site",
134
+ title: "Docs Site",
135
+ description: "Documentation publishing and content workflows",
136
+ active: true,
137
+ },
138
+ });
139
+ const duplicateScope = await memoryCreateScope.handler({
140
+ slug: "docs-site",
141
+ title: "Docs Site Again",
142
+ }, {});
143
+ assert.match(String(duplicateScope), /already exists/i);
144
+ const invalidScope = await memoryCreateScope.handler({
145
+ slug: "Docs Site",
146
+ title: "Docs Site",
147
+ }, {});
148
+ assert.match(String(invalidScope), /kebab-case/i);
124
149
  const implicitScopeRemember = await chapterhouseRemember.handler({
125
150
  content: "Implicit active-scope writes should route to chapterhouse.",
126
151
  kind: "observation",
@@ -151,7 +176,7 @@ test("memory tools remember, recall, set scope, and enforce orchestrator-only wr
151
176
  }, {});
152
177
  assert.equal(typeof coderRecalled, "object");
153
178
  assert.equal((coderRecalled.hits ?? []).some((hit) => hit.id === row.id), true);
154
- assert.ok(memoryRemember && memoryRecall && memorySetScope);
179
+ assert.ok(memoryRemember && memoryRecall && memorySetScope && memoryCreateScope);
155
180
  });
156
181
  test("memory_propose queues pending proposals, defaults scope from the active scope, and captures delegated task context", async () => {
157
182
  const { toolsModule, agentsModule, dbModule } = await loadModules();
@@ -195,11 +220,10 @@ test("memory_propose queues pending proposals, defaults scope from the active sc
195
220
  assert.equal(payload.reason, "The user explicitly asked for the new proposal path.");
196
221
  assert.equal(payload.payload.content, "Subagents can queue durable observations for orchestrator review.");
197
222
  });
198
- test("memory_propose from a persistent agent is bound to that agent's scope", async () => {
223
+ test("memory_propose from a persistent agent honors an explicit scope_slug", async () => {
199
224
  const home = process.env.CHAPTERHOUSE_HOME;
200
225
  assert.ok(home, "test home should be set");
201
- const chapterhouseHome = home.endsWith(".chapterhouse") ? home : join(home, ".chapterhouse");
202
- const agentsDir = join(chapterhouseHome, "agents");
226
+ const { AGENTS_DIR: agentsDir } = await import("../paths.js");
203
227
  mkdirSync(agentsDir, { recursive: true });
204
228
  writeFileSync(join(agentsDir, "bellonda.agent.md"), [
205
229
  "---",
@@ -228,7 +252,7 @@ test("memory_propose from a persistent agent is bound to that agent's scope", as
228
252
  kind: "observation",
229
253
  scope_slug: "chapterhouse",
230
254
  payload: {
231
- content: "Persistent agents should not be able to write proposals outside their bound scope.",
255
+ content: "Persistent agents can explicitly route proposals to another scope when needed.",
232
256
  },
233
257
  }, {}));
234
258
  assert.equal(proposed.status, "queued");
@@ -241,7 +265,51 @@ test("memory_propose from a persistent agent is bound to that agent's scope", as
241
265
  assert.equal(row.source_agent, "bellonda");
242
266
  assert.equal(row.source_task_id, "task-persistent-scope-001");
243
267
  const payload = JSON.parse(row.payload);
244
- assert.equal(payload.scope_slug, "infra");
268
+ assert.equal(payload.scope_slug, "chapterhouse");
269
+ });
270
+ test("memory_propose ignores a missing subagent bound scope and falls back to the active scope", async () => {
271
+ const home = process.env.CHAPTERHOUSE_HOME;
272
+ assert.ok(home, "test home should be set");
273
+ const { AGENTS_DIR: agentsDir } = await import("../paths.js");
274
+ mkdirSync(agentsDir, { recursive: true });
275
+ writeFileSync(join(agentsDir, "hwi.agent.md"), [
276
+ "---",
277
+ "name: Hwi",
278
+ "description: Mentat of Brian's personal scope",
279
+ "model: claude-sonnet-4.6",
280
+ "persistent: true",
281
+ "scope: brian",
282
+ "---",
283
+ "",
284
+ "You are Hwi.",
285
+ ].join("\n"));
286
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
287
+ agentsModule.loadAgents();
288
+ const tools = toolsModule.createTools({
289
+ client: { async listModels() { return []; } },
290
+ onAgentTaskComplete: () => { },
291
+ });
292
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
293
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
294
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
295
+ const hwiTools = bindToolsToAgent("hwi", tools, "task-hwi-action-item");
296
+ await findTool(chapterhouseTools, "memory_set_scope").handler({ slug: "chapterhouse" }, {});
297
+ const proposed = await findTool(hwiTools, "memory_propose").handler({
298
+ kind: "action_item",
299
+ payload: {
300
+ title: "Follow up with Brian",
301
+ },
302
+ }, {});
303
+ assert.equal(proposed.status, "queued");
304
+ const row = dbModule.getDb().prepare(`
305
+ SELECT source_agent, payload
306
+ FROM mem_inbox
307
+ WHERE id = ?
308
+ `).get(proposed.proposal_id);
309
+ assert.ok(row, "memory_propose should insert a mem_inbox row");
310
+ assert.equal(row.source_agent, "hwi");
311
+ const payload = JSON.parse(row.payload);
312
+ assert.equal(payload.scope_slug, "chapterhouse");
245
313
  });
246
314
  test("memory_propose accepts entity proposals with entity_kind and queues the full payload", async () => {
247
315
  const { toolsModule, agentsModule, dbModule } = await loadModules();
@@ -367,6 +435,26 @@ test("memory_propose accepts action_item proposals with a resolvable payload sha
367
435
  assert.equal(payload.payload.title, "Remind infra about high disk usage");
368
436
  assert.equal(payload.payload.detail, "Next time disk exceeds 85%, notify Bellonda.");
369
437
  });
438
+ test("memory_propose rejects action_item proposals with blank titles before queuing", async () => {
439
+ const { toolsModule, dbModule } = await loadModules();
440
+ const tools = toolsModule.createTools({
441
+ client: { async listModels() { return []; } },
442
+ onAgentTaskComplete: () => { },
443
+ });
444
+ const memoryPropose = findTool(tools, "memory_propose");
445
+ const result = await memoryPropose.handler({
446
+ kind: "action_item",
447
+ payload: {
448
+ title: " ",
449
+ },
450
+ }, {});
451
+ assert.match(String(result), /title/i);
452
+ const queued = dbModule.getDb().prepare(`
453
+ SELECT COUNT(*) AS count
454
+ FROM mem_inbox
455
+ `).get();
456
+ assert.equal(queued.count, 0);
457
+ });
370
458
  test("memory_propose rejects invalid proposal kinds", async () => {
371
459
  const { toolsModule } = await loadModules();
372
460
  const tools = toolsModule.createTools({
package/dist/daemon.js CHANGED
@@ -4,6 +4,7 @@ import { stopEpisodeWriter } from "./copilot/episode-writer.js";
4
4
  import { startApiServer, broadcastToSSE } from "./api/server.js";
5
5
  import { getDb, closeDb, getState } from "./store/db.js";
6
6
  import { config } from "./config.js";
7
+ import { ModeContext } from "./mode-context.js";
7
8
  import { spawn } from "child_process";
8
9
  import { readdirSync, statSync, rmSync } from "fs";
9
10
  import { join } from "path";
@@ -21,6 +22,7 @@ import { CHAPTERHOUSE_VERSION } from "./version.js";
21
22
  import { isWorkiqAutoInstallEnabled, ensureWorkiqMcpEntry } from "./copilot/workiq-installer.js";
22
23
  import { MemoryHousekeepingScheduler } from "./memory/housekeeping-scheduler.js";
23
24
  const log = logger.child({ module: "daemon" });
25
+ const modeContext = new ModeContext(config);
24
26
  let memoryHousekeepingScheduler;
25
27
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
26
28
  /**
@@ -86,6 +88,9 @@ async function main() {
86
88
  if (config.selfEditEnabled) {
87
89
  log.warn("Self-edit mode enabled — Chapterhouse can modify his own source code");
88
90
  }
91
+ for (const warning of config.modeCompatibilityWarnings) {
92
+ log.warn({ mode: config.chapterhouseMode }, warning);
93
+ }
89
94
  // Set up message logging to daemon console
90
95
  setMessageLogger((direction, source, text) => {
91
96
  const arrow = direction === "in" ? "⟶" : "⟵";
@@ -100,7 +105,7 @@ async function main() {
100
105
  if (wikiIsNew) {
101
106
  log.info("Created wiki");
102
107
  }
103
- if (config.chapterhouseMode === "team") {
108
+ if (modeContext.isTeam()) {
104
109
  const seed = seedTeamWiki();
105
110
  if (seed.created.length > 0) {
106
111
  log.info({ pages: seed.created }, "Seeded team wiki pages");
@@ -153,7 +158,7 @@ async function main() {
153
158
  await startApiServer();
154
159
  memoryHousekeepingScheduler = new MemoryHousekeepingScheduler();
155
160
  memoryHousekeepingScheduler.start();
156
- if (config.chapterhouseMode === "personal" && (config.adoPat || config.teamChapterhouseUrl)) {
161
+ if (modeContext.canLogToAdo() && (config.adoPat || config.teamChapterhouseUrl)) {
157
162
  new StandupScheduler().schedule();
158
163
  }
159
164
  const url = `http://${getDisplayHost(config.apiHost)}:${config.apiPort}`;
@@ -1,4 +1,5 @@
1
1
  import { config } from "../config.js";
2
+ import { ModeContext } from "../mode-context.js";
2
3
  export class TeamPushClient {
3
4
  teamChapterhouseUrl;
4
5
  teamChapterhouseToken;
@@ -6,6 +7,7 @@ export class TeamPushClient {
6
7
  fetchImpl;
7
8
  getAuthorizationHeader;
8
9
  getCurrentUser;
10
+ modeContext;
9
11
  constructor(options = {}) {
10
12
  this.teamChapterhouseUrl = (options.teamChapterhouseUrl ?? config.teamChapterhouseUrl).trim().replace(/\/+$/, "");
11
13
  this.teamChapterhouseToken = (options.teamChapterhouseToken ?? config.teamChapterhouseToken).trim();
@@ -13,6 +15,11 @@ export class TeamPushClient {
13
15
  this.fetchImpl = options.fetchImpl ?? fetch;
14
16
  this.getAuthorizationHeader = options.getAuthorizationHeader;
15
17
  this.getCurrentUser = options.getCurrentUser;
18
+ this.modeContext = new ModeContext({
19
+ ...config,
20
+ teamChapterhouseUrl: this.teamChapterhouseUrl,
21
+ standaloneMode: this.standaloneMode,
22
+ });
16
23
  }
17
24
  async pushUpdate(payload) {
18
25
  if (!this.isEnabled()) {
@@ -129,7 +136,7 @@ export class TeamPushClient {
129
136
  throw new Error("Failed to push OKR update: no authenticated engineer identity is available");
130
137
  }
131
138
  isEnabled() {
132
- return !this.standaloneMode && this.teamChapterhouseUrl.length > 0;
139
+ return this.modeContext.canSyncTeamWiki();
133
140
  }
134
141
  }
135
142
  function describeHttpFailure(status) {
@@ -1,4 +1,5 @@
1
1
  import { config } from "../config.js";
2
+ import { ModeContext } from "../mode-context.js";
2
3
  import { childLogger } from "../util/logger.js";
3
4
  const log = childLogger("teams-notify");
4
5
  export const TEAMS_MILESTONE_THRESHOLDS = [25, 50, 75, 100];
@@ -8,11 +9,17 @@ export class TeamsNotifier {
8
9
  enabled;
9
10
  fetchImpl;
10
11
  warn;
12
+ modeContext;
11
13
  constructor(options = {}) {
12
14
  this.webhookUrl = (options.webhookUrl ?? config.teamsWebhookUrl).trim();
13
15
  this.enabled = options.enabled ?? config.teamsNotificationsEnabled;
14
16
  this.fetchImpl = options.fetchImpl ?? fetch;
15
17
  this.warn = options.warn ?? ((message) => log.warn(message));
18
+ this.modeContext = new ModeContext({
19
+ ...config,
20
+ teamsWebhookUrl: this.webhookUrl,
21
+ teamsNotificationsEnabled: this.enabled,
22
+ });
16
23
  }
17
24
  async sendMessage(title, body, color = DEFAULT_COLOR) {
18
25
  return await this.postCard({
@@ -61,7 +68,7 @@ export class TeamsNotifier {
61
68
  });
62
69
  }
63
70
  async postCard(card) {
64
- if (!this.enabled || this.webhookUrl.length === 0) {
71
+ if (!this.modeContext.canSyncToTeams()) {
65
72
  this.warn("[teams] Teams notifications are disabled or TEAMS_WEBHOOK_URL is empty.");
66
73
  return false;
67
74
  }
@@ -38,11 +38,16 @@ test("active scope can be set, read, and cleared without changing scope activati
38
38
  const getActiveScope = getFunction(memoryModule, "getActiveScope");
39
39
  const setActiveScope = getFunction(memoryModule, "setActiveScope");
40
40
  const deactivateScope = getFunction(memoryModule, "deactivateScope");
41
+ const createScope = getFunction(memoryModule, "createScope");
41
42
  assert.equal(getActiveScope(), null);
42
43
  assert.equal(setActiveScope("chapterhouse")?.slug, "chapterhouse");
43
44
  assert.equal(getActiveScope()?.slug, "chapterhouse");
44
- const team = getScope("team");
45
- assert.ok(team);
45
+ const team = createScope({
46
+ slug: "team",
47
+ title: "Team",
48
+ description: "Team test scope",
49
+ keywords: ["team"],
50
+ });
46
51
  const deactivated = deactivateScope(team.id);
47
52
  assert.equal(deactivated.active, false);
48
53
  assert.equal(getActiveScope()?.slug, "chapterhouse");