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.
- package/dist/api/server.js +31 -3
- package/dist/api/server.test.js +48 -5
- package/dist/cli.js +4 -2
- package/dist/config.js +75 -13
- package/dist/config.test.js +44 -0
- package/dist/copilot/orchestrator.js +17 -6
- package/dist/copilot/orchestrator.test.js +50 -3
- package/dist/copilot/router.js +43 -8
- package/dist/copilot/router.test.js +30 -18
- package/dist/copilot/system-message.js +3 -3
- package/dist/copilot/system-message.test.js +10 -0
- package/dist/copilot/tools.js +79 -12
- package/dist/copilot/tools.memory.test.js +94 -6
- package/dist/daemon.js +7 -2
- package/dist/integrations/team-push.js +8 -1
- package/dist/integrations/teams-notify.js +8 -1
- package/dist/memory/active-scope.test.js +7 -2
- package/dist/memory/eot.js +96 -8
- package/dist/memory/eot.test.js +186 -5
- package/dist/memory/hot-tier.test.js +14 -4
- package/dist/memory/housekeeping.js +73 -25
- package/dist/memory/housekeeping.test.js +115 -16
- package/dist/memory/inbox.test.js +178 -0
- package/dist/memory/scopes.test.js +0 -24
- package/dist/memory/tiering.test.js +323 -0
- package/dist/mode-context.js +28 -0
- package/dist/mode-context.test.js +42 -0
- package/dist/setup.js +152 -95
- package/dist/setup.test.js +122 -0
- package/dist/store/db.js +0 -18
- package/dist/wiki/team-sync.js +8 -1
- package/package.json +1 -1
- package/web/dist/assets/{index-BfHqP3-C.js → index-CPaILy2j.js} +69 -69
- package/web/dist/assets/index-CPaILy2j.js.map +1 -0
- package/web/dist/assets/index-Cs7AGeaL.css +10 -0
- package/web/dist/index.html +2 -2
- package/agents/bellonda.agent.md +0 -11
- package/agents/hwi-noree.agent.md +0 -12
- package/web/dist/assets/index-BfHqP3-C.js.map +0 -1
- 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
|
|
25
|
-
const { router
|
|
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:
|
|
34
|
+
enabled: true,
|
|
30
35
|
tierModels: {
|
|
31
36
|
fast: "gpt-4.1",
|
|
32
37
|
standard: "claude-sonnet-4.6",
|
|
33
|
-
premium: "claude-
|
|
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-
|
|
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
|
-
...
|
|
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
|
|
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-
|
|
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-
|
|
103
|
+
model: "claude-sonnet-4.6",
|
|
92
104
|
tier: "premium",
|
|
93
|
-
switched:
|
|
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.
|
|
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
|
|
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
|
|
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, "\\$&")));
|
package/dist/copilot/tools.js
CHANGED
|
@@ -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
|
-
|
|
70
|
-
if (!agentSlug || agentSlug === "chapterhouse") {
|
|
71
|
+
if (requestedScopeSlug) {
|
|
71
72
|
return requestedScopeSlug;
|
|
72
73
|
}
|
|
73
|
-
const
|
|
74
|
-
if (
|
|
75
|
-
return
|
|
74
|
+
const agentSlug = agentsModule.getCurrentToolAgentSlug?.();
|
|
75
|
+
if (!agentSlug || agentSlug === "chapterhouse") {
|
|
76
|
+
return getMemoryActiveScope()?.slug;
|
|
76
77
|
}
|
|
77
78
|
const agent = getAgent(agentSlug);
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
const boundScope = agent?.scope ?? loadAgents().find((entry) => entry.slug === agentSlug)?.scope;
|
|
80
|
+
if (boundScope && getMemoryScope(boundScope)) {
|
|
81
|
+
return boundScope;
|
|
80
82
|
}
|
|
81
|
-
return
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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, "
|
|
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 (
|
|
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 (
|
|
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
|
|
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.
|
|
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 =
|
|
45
|
-
|
|
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");
|