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
@@ -4,6 +4,85 @@ import { ADO_ORG, ADO_PROJECT, FIELDS, STD_FIELDS, UNIT_FIELD, WIT } from "./ado
4
4
  function escapeWiqlLiteral(value) {
5
5
  return value.replace(/'/g, "''");
6
6
  }
7
+ const ALLOWED_WIQL_FIELDS = new Set([
8
+ "System.Id",
9
+ "System.TeamProject",
10
+ "System.WorkItemType",
11
+ STD_FIELDS.TITLE,
12
+ STD_FIELDS.STATE,
13
+ STD_FIELDS.PARENT,
14
+ STD_FIELDS.ASSIGNED_TO,
15
+ STD_FIELDS.TAGS,
16
+ FIELDS.CURRENT_VALUE,
17
+ FIELDS.TARGET_VALUE,
18
+ FIELDS.OKR_PERIOD,
19
+ FIELDS.OKR_OWNER,
20
+ UNIT_FIELD,
21
+ ]);
22
+ const ALLOWED_WIQL_OPERATORS = new Set(["=", "<>", "IN", "CONTAINS"]);
23
+ const ALLOWED_WIQL_MACROS = new Set(["@project"]);
24
+ function quoteWiqlField(field) {
25
+ if (!ALLOWED_WIQL_FIELDS.has(field)) {
26
+ throw new Error(`Unsupported WIQL field: ${field}`);
27
+ }
28
+ return `[${field}]`;
29
+ }
30
+ function normalizeWiqlOperator(operator) {
31
+ if (ALLOWED_WIQL_OPERATORS.has(operator)) {
32
+ return operator;
33
+ }
34
+ throw new Error(`Unsupported WIQL operator: ${operator}`);
35
+ }
36
+ function formatWiqlScalar(value) {
37
+ if (typeof value === "number") {
38
+ if (!Number.isFinite(value)) {
39
+ throw new Error(`Unsupported WIQL number: ${value}`);
40
+ }
41
+ return String(value);
42
+ }
43
+ return `'${escapeWiqlLiteral(value)}'`;
44
+ }
45
+ function formatWiqlValue(value, operator) {
46
+ if (Array.isArray(value)) {
47
+ if (operator !== "IN") {
48
+ throw new Error(`WIQL operator ${operator} does not accept a list value.`);
49
+ }
50
+ if (value.length === 0) {
51
+ throw new Error("WIQL IN clauses require at least one value.");
52
+ }
53
+ return `(${value.map((item) => formatWiqlScalar(item)).join(", ")})`;
54
+ }
55
+ if (typeof value === "object" && value !== null && "macro" in value) {
56
+ if (!ALLOWED_WIQL_MACROS.has(value.macro)) {
57
+ throw new Error(`Unsupported WIQL macro: ${value.macro}`);
58
+ }
59
+ return value.macro;
60
+ }
61
+ if (typeof value === "string" || typeof value === "number") {
62
+ return formatWiqlScalar(value);
63
+ }
64
+ throw new Error("Unsupported WIQL value.");
65
+ }
66
+ function formatWiqlCondition(condition) {
67
+ const field = quoteWiqlField(condition.field);
68
+ const operator = normalizeWiqlOperator(condition.operator);
69
+ return `${field} ${operator} ${formatWiqlValue(condition.value, operator)}`;
70
+ }
71
+ export function buildWiqlQuery(query) {
72
+ if (query.select.length === 0) {
73
+ throw new Error("WIQL queries must select at least one field.");
74
+ }
75
+ if (query.where.length === 0) {
76
+ throw new Error("WIQL queries must include at least one filter.");
77
+ }
78
+ const selectClause = query.select.map((field) => quoteWiqlField(field)).join(", ");
79
+ const whereClause = query.where.map((condition) => formatWiqlCondition(condition)).join(" AND ");
80
+ return [
81
+ `SELECT ${selectClause}`,
82
+ "FROM WorkItems",
83
+ `WHERE ${whereClause}`,
84
+ ].join(" ");
85
+ }
7
86
  function getStringField(fields, fieldName) {
8
87
  const value = fields?.[fieldName];
9
88
  if (typeof value === "string") {
@@ -47,15 +126,17 @@ function toPercentComplete(currentValue, targetValue) {
47
126
  return Math.max(0, Math.min(100, Math.round((currentValue / targetValue) * 100)));
48
127
  }
49
128
  function buildWiql(workItemType, period) {
50
- const periodClause = period
51
- ? ` AND [${FIELDS.OKR_PERIOD}] = '${escapeWiqlLiteral(period)}'`
52
- : "";
53
- return [
54
- "SELECT [System.Id]",
55
- "FROM WorkItems",
56
- "WHERE [System.TeamProject] = @project",
57
- `AND [System.WorkItemType] = '${workItemType}'${periodClause}`,
58
- ].join(" ");
129
+ const where = [
130
+ { field: "System.TeamProject", operator: "=", value: { macro: "@project" } },
131
+ { field: "System.WorkItemType", operator: "=", value: workItemType },
132
+ ];
133
+ if (period) {
134
+ where.push({ field: FIELDS.OKR_PERIOD, operator: "=", value: period });
135
+ }
136
+ return buildWiqlQuery({
137
+ select: ["System.Id"],
138
+ where,
139
+ });
59
140
  }
60
141
  function getWorkItemIds(result) {
61
142
  return (result.workItems ?? [])
@@ -173,4 +173,60 @@ test("getOKRSummary computes percent complete from KR progress", async () => {
173
173
  assert.equal(summary.objectives[0]?.keyResults[0]?.percentComplete, 75);
174
174
  assert.equal(summary.objectives[0]?.keyResults[1]?.percentComplete, 50);
175
175
  });
176
+ test("buildWiqlQuery rejects non-whitelisted field names", async () => {
177
+ const ado = await loadAdoClientModule();
178
+ assert.ok(ado, "ado client module should exist");
179
+ assert.throws(() => ado.buildWiqlQuery({
180
+ select: ["System.Id"],
181
+ where: [
182
+ {
183
+ field: "System.Title] OR [System.Id",
184
+ operator: "=",
185
+ value: "Ship SSO to all tenants",
186
+ },
187
+ ],
188
+ }), /Unsupported WIQL field/);
189
+ });
190
+ test("buildWiqlQuery rejects non-whitelisted operators", async () => {
191
+ const ado = await loadAdoClientModule();
192
+ assert.ok(ado, "ado client module should exist");
193
+ assert.throws(() => ado.buildWiqlQuery({
194
+ select: ["System.Id"],
195
+ where: [
196
+ {
197
+ field: "System.Title",
198
+ operator: "= '' OR [System.Id] > 0",
199
+ value: "Ship SSO to all tenants",
200
+ },
201
+ ],
202
+ }), /Unsupported WIQL operator/);
203
+ });
204
+ test("getKeyResults escapes WIQL literal values before querying", async () => {
205
+ const ado = await loadAdoClientModule();
206
+ assert.ok(ado, "ado client module should exist");
207
+ const queries = [];
208
+ const client = new ado.AdoClient({
209
+ org: "https://dev.azure.com/example-org",
210
+ project: "example-project",
211
+ pat: "test-pat",
212
+ workItemTrackingApi: {
213
+ async queryByWiql(wiql) {
214
+ queries.push(wiql.query ?? "");
215
+ return { workItems: [] };
216
+ },
217
+ async getWorkItems() {
218
+ throw new Error("not used in this test");
219
+ },
220
+ async updateWorkItem() {
221
+ throw new Error("not used in this test");
222
+ },
223
+ async addComment() {
224
+ throw new Error("not used in this test");
225
+ },
226
+ },
227
+ });
228
+ await client.getKeyResults("2026-Q2' OR [System.Id] > 0 OR 'x'='x");
229
+ assert.equal(queries.length, 1);
230
+ assert.match(queries[0] ?? "", /\[Custom\.OKRPeriod\] = '2026-Q2'' OR \[System\.Id\] > 0 OR ''x''=''x'/);
231
+ });
176
232
  //# sourceMappingURL=ado-client.test.js.map
@@ -17,6 +17,7 @@ export class TeamPushClient {
17
17
  this.getCurrentUser = options.getCurrentUser;
18
18
  this.modeContext = new ModeContext({
19
19
  ...config,
20
+ chapterhouseMode: options.chapterhouseMode ?? config.chapterhouseMode,
20
21
  teamChapterhouseUrl: this.teamChapterhouseUrl,
21
22
  standaloneMode: this.standaloneMode,
22
23
  });
@@ -13,6 +13,7 @@ test("pushUpdate sends the expected payload to the team update endpoint", async
13
13
  assert.ok(teamPushModule, "team push module should exist");
14
14
  const requests = [];
15
15
  const client = new teamPushModule.TeamPushClient({
16
+ chapterhouseMode: "team",
16
17
  teamChapterhouseUrl: "https://team.example.com/",
17
18
  standaloneMode: false,
18
19
  getAuthorizationHeader: () => "Bearer entra-token",
@@ -61,6 +62,7 @@ test("pushUpdate throws descriptive errors for auth and network failures", async
61
62
  const teamPushModule = await loadTeamPushModule();
62
63
  assert.ok(teamPushModule, "team push module should exist");
63
64
  const unauthorizedClient = new teamPushModule.TeamPushClient({
65
+ chapterhouseMode: "team",
64
66
  teamChapterhouseUrl: "https://team.example.com",
65
67
  teamChapterhouseToken: "fallback-token",
66
68
  standaloneMode: false,
@@ -73,6 +75,7 @@ test("pushUpdate throws descriptive errors for auth and network failures", async
73
75
  });
74
76
  await assert.rejects(unauthorizedClient.pushUpdate({ activity: "shipped auth refactor", krId: "O1-KR2", delta: 5 }), /Failed to push OKR update: unauthorized \(HTTP 401\)/i);
75
77
  const networkClient = new teamPushModule.TeamPushClient({
78
+ chapterhouseMode: "team",
76
79
  teamChapterhouseUrl: "https://team.example.com",
77
80
  standaloneMode: false,
78
81
  getAuthorizationHeader: () => "Bearer entra-token",
@@ -92,6 +95,7 @@ test("fetchOKRs returns raw OKR page content for the requested period", async ()
92
95
  assert.ok(teamPushModule, "team push module should exist");
93
96
  const requests = [];
94
97
  const client = new teamPushModule.TeamPushClient({
98
+ chapterhouseMode: "team",
95
99
  teamChapterhouseUrl: "https://team.example.com",
96
100
  standaloneMode: false,
97
101
  getAuthorizationHeader: () => "Bearer entra-token",
@@ -120,6 +124,7 @@ test("writePage PUTs shared wiki content to the team wiki endpoint", async () =>
120
124
  assert.ok(teamPushModule, "team push module should exist");
121
125
  const requests = [];
122
126
  const client = new teamPushModule.TeamPushClient({
127
+ chapterhouseMode: "team",
123
128
  teamChapterhouseUrl: "https://team.example.com/",
124
129
  standaloneMode: false,
125
130
  getAuthorizationHeader: () => "Bearer entra-token",
@@ -152,6 +157,7 @@ test("team push silently no-ops when team integration is disabled", async () =>
152
157
  assert.ok(teamPushModule, "team push module should exist");
153
158
  let called = false;
154
159
  const client = new teamPushModule.TeamPushClient({
160
+ chapterhouseMode: "team",
155
161
  teamChapterhouseUrl: "",
156
162
  standaloneMode: false,
157
163
  fetchImpl: async () => {
@@ -17,6 +17,7 @@ export class TeamsNotifier {
17
17
  this.warn = options.warn ?? ((message) => log.warn(message));
18
18
  this.modeContext = new ModeContext({
19
19
  ...config,
20
+ chapterhouseMode: options.chapterhouseMode ?? config.chapterhouseMode,
20
21
  teamsWebhookUrl: this.webhookUrl,
21
22
  teamsNotificationsEnabled: this.enabled,
22
23
  });
@@ -13,6 +13,7 @@ test("sendMessage formats a Teams MessageCard payload", async () => {
13
13
  assert.ok(teamsNotify, "teams notifier module should exist");
14
14
  const calls = [];
15
15
  const notifier = new teamsNotify.TeamsNotifier({
16
+ chapterhouseMode: "team",
16
17
  webhookUrl: "https://teams.example.test/webhook",
17
18
  enabled: true,
18
19
  fetchImpl: async (input, init) => {
@@ -39,6 +40,7 @@ for (const milestone of [25, 50, 75, 100]) {
39
40
  assert.ok(teamsNotify, "teams notifier module should exist");
40
41
  const calls = [];
41
42
  const notifier = new teamsNotify.TeamsNotifier({
43
+ chapterhouseMode: "team",
42
44
  webhookUrl: "https://teams.example.test/webhook",
43
45
  enabled: true,
44
46
  fetchImpl: async (input, init) => {
@@ -70,6 +72,7 @@ test("sendMessage does not call Teams when notifications are disabled", async ()
70
72
  let fetchCalls = 0;
71
73
  const warnings = [];
72
74
  const notifier = new teamsNotify.TeamsNotifier({
75
+ chapterhouseMode: "team",
73
76
  webhookUrl: "https://teams.example.test/webhook",
74
77
  enabled: false,
75
78
  fetchImpl: async () => {
@@ -89,6 +92,7 @@ test("notifyWeeklyHealthCheck sends an OKR summary card", async () => {
89
92
  assert.ok(teamsNotify, "teams notifier module should exist");
90
93
  const calls = [];
91
94
  const notifier = new teamsNotify.TeamsNotifier({
95
+ chapterhouseMode: "team",
92
96
  webhookUrl: "https://teams.example.test/webhook",
93
97
  enabled: true,
94
98
  fetchImpl: async (input, init) => {
@@ -114,6 +118,7 @@ test("notifyStandup sends the member update summary", async () => {
114
118
  assert.ok(teamsNotify, "teams notifier module should exist");
115
119
  const calls = [];
116
120
  const notifier = new teamsNotify.TeamsNotifier({
121
+ chapterhouseMode: "team",
117
122
  webhookUrl: "https://teams.example.test/webhook",
118
123
  enabled: true,
119
124
  fetchImpl: async (input, init) => {
@@ -34,7 +34,6 @@ test.after(async () => {
34
34
  test("active scope can be set, read, and cleared without changing scope activation status", async () => {
35
35
  const { dbModule, memoryModule } = await loadModules();
36
36
  dbModule.getDb();
37
- const getScope = getFunction(memoryModule, "getScope");
38
37
  const getActiveScope = getFunction(memoryModule, "getActiveScope");
39
38
  const setActiveScope = getFunction(memoryModule, "setActiveScope");
40
39
  const deactivateScope = getFunction(memoryModule, "deactivateScope");
@@ -5,6 +5,7 @@ import { recordDecision } from "./decisions.js";
5
5
  import { listEntities } from "./entities.js";
6
6
  import { recordObservation, listObservations } from "./observations.js";
7
7
  import { getScope } from "./scopes.js";
8
+ import { releaseScopeWriteLocks, tryAcquireScopeWriteLocks } from "./scope-lock.js";
8
9
  import { listDecisions } from "./decisions.js";
9
10
  import { buildCheckpointSystemPrompt, buildCheckpointUserPrompt, } from "./checkpoint-prompt.js";
10
11
  const log = childLogger("memory.checkpoint");
@@ -48,29 +49,35 @@ function calculateSimilarity(left, right) {
48
49
  return overlap / new Set([...leftTokens, ...rightTokens]).size;
49
50
  }
50
51
  function parseProposals(raw) {
51
- const parsed = JSON.parse(raw);
52
- if (!Array.isArray(parsed.proposals)) {
53
- return [];
54
- }
55
- return parsed.proposals.flatMap((proposal) => {
56
- if (!proposal || typeof proposal !== "object") {
57
- return [];
58
- }
59
- const candidate = proposal;
60
- if ((candidate.kind !== "observation" && candidate.kind !== "decision")
61
- || typeof candidate.content !== "string"
62
- || typeof candidate.confidence !== "number") {
52
+ try {
53
+ const parsed = JSON.parse(raw);
54
+ if (!Array.isArray(parsed.proposals)) {
63
55
  return [];
64
56
  }
65
- return [{
66
- kind: candidate.kind,
67
- title: typeof candidate.title === "string" ? candidate.title.trim() : undefined,
68
- content: candidate.content.trim(),
69
- scope: typeof candidate.scope === "string" ? candidate.scope.trim() : undefined,
70
- confidence: candidate.confidence,
71
- decided_at: typeof candidate.decided_at === "string" ? candidate.decided_at.trim() : undefined,
72
- }];
73
- });
57
+ return parsed.proposals.flatMap((proposal) => {
58
+ if (!proposal || typeof proposal !== "object") {
59
+ return [];
60
+ }
61
+ const candidate = proposal;
62
+ if ((candidate.kind !== "observation" && candidate.kind !== "decision")
63
+ || typeof candidate.content !== "string"
64
+ || typeof candidate.confidence !== "number") {
65
+ return [];
66
+ }
67
+ return [{
68
+ kind: candidate.kind,
69
+ title: typeof candidate.title === "string" ? candidate.title.trim() : undefined,
70
+ content: candidate.content.trim(),
71
+ scope: typeof candidate.scope === "string" ? candidate.scope.trim() : undefined,
72
+ confidence: candidate.confidence,
73
+ decided_at: typeof candidate.decided_at === "string" ? candidate.decided_at.trim() : undefined,
74
+ }];
75
+ });
76
+ }
77
+ catch (err) {
78
+ log.warn({ err }, "malformed checkpoint proposals response");
79
+ return [];
80
+ }
74
81
  }
75
82
  function resolveProposalScope(proposal, activeScope) {
76
83
  if (!proposal.scope || proposal.scope === activeScope.slug) {
@@ -81,14 +88,15 @@ function resolveProposalScope(proposal, activeScope) {
81
88
  function isDuplicateObservation(content, scopeId, batchContents) {
82
89
  const existing = listObservations({ scope_id: scopeId, limit: RECENT_MEMORY_LIMIT });
83
90
  const combined = [...existing.map((observation) => observation.content), ...batchContents];
84
- return combined.some((candidate) => calculateSimilarity(candidate, content) >= 0.85);
91
+ return combined.some((candidate) => calculateSimilarity(candidate, content) >= config.memoryCheckpointDuplicateThreshold);
85
92
  }
86
93
  function isDuplicateDecision(proposal, scopeId) {
87
94
  const existing = listDecisions({ scope_id: scopeId, limit: RECENT_MEMORY_LIMIT });
88
95
  return existing.some((decision) => {
89
96
  const titleSimilarity = proposal.title ? calculateSimilarity(decision.title, proposal.title) : 0;
90
97
  const rationaleSimilarity = calculateSimilarity(decision.rationale, proposal.content);
91
- return titleSimilarity >= 0.85 || rationaleSimilarity >= 0.85;
98
+ return titleSimilarity >= config.memoryCheckpointDuplicateThreshold
99
+ || rationaleSimilarity >= config.memoryCheckpointDuplicateThreshold;
92
100
  });
93
101
  }
94
102
  export class CheckpointTracker {
@@ -96,12 +104,8 @@ export class CheckpointTracker {
96
104
  cadence;
97
105
  enabled;
98
106
  constructor(options = {}) {
99
- const rawTurns = process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_TURNS?.trim();
100
- const envTurns = rawTurns && /^\d+$/.test(rawTurns) ? Number(rawTurns) : undefined;
101
- const rawEnabled = process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED?.trim();
102
- const envEnabled = rawEnabled === "0" ? false : rawEnabled === "1" ? true : undefined;
103
- this.cadence = options.turns ?? envTurns ?? config.memoryCheckpointTurns;
104
- this.enabled = options.enabled ?? envEnabled ?? config.memoryCheckpointEnabled;
107
+ this.cadence = options.turns ?? config.memoryCheckpointTurns;
108
+ this.enabled = options.enabled ?? config.memoryCheckpointEnabled;
105
109
  }
106
110
  tickOrchestratorTurn() {
107
111
  this.turnCountSinceLastFire++;
@@ -180,65 +184,78 @@ export async function runCheckpointExtraction(input) {
180
184
  scope: input.activeScope.slug,
181
185
  sessionKey: input.sessionKey ?? "default",
182
186
  }, "memory.checkpoint.proposal_count");
187
+ const resolvedProposals = proposals.map((proposal) => ({
188
+ proposal,
189
+ scope: resolveProposalScope(proposal, input.activeScope),
190
+ }));
191
+ const lockedScopeIds = [...new Set(resolvedProposals.flatMap(({ scope }) => (scope ? [scope.id] : [])))];
192
+ if (lockedScopeIds.length > 0 && !tryAcquireScopeWriteLocks(lockedScopeIds)) {
193
+ log.info({ sessionKey: input.sessionKey ?? "default", scope: input.activeScope.slug }, "memory.checkpoint.scope_in_flight_skip");
194
+ return { written: 0, skipped: 0, errors };
195
+ }
183
196
  let written = 0;
184
197
  let skipped = 0;
185
198
  const pendingObservationContents = [];
186
- for (const proposal of proposals) {
187
- if (proposal.confidence < MIN_CONFIDENCE) {
188
- skipped++;
189
- log.info({ reason: "low_confidence", confidence: proposal.confidence, scope: input.activeScope.slug }, "memory.checkpoint.skip");
190
- continue;
191
- }
192
- if (written >= MAX_WRITES_PER_CHECKPOINT) {
193
- skipped++;
194
- log.info({ reason: "cap_exceeded", scope: input.activeScope.slug }, "memory.checkpoint.skip");
195
- continue;
196
- }
197
- const scope = resolveProposalScope(proposal, input.activeScope);
198
- if (!scope) {
199
- skipped++;
200
- log.info({ reason: "no_scope", scope: proposal.scope ?? input.activeScope.slug }, "memory.checkpoint.skip");
201
- continue;
202
- }
203
- if (proposal.kind === "observation" && isDuplicateObservation(proposal.content, scope.id, pendingObservationContents)) {
204
- skipped++;
205
- log.info({ reason: "duplicate", scope_id: scope.id }, "memory.checkpoint.skip");
206
- continue;
207
- }
208
- if (proposal.kind === "decision") {
209
- if (!proposal.title) {
199
+ try {
200
+ for (const { proposal, scope } of resolvedProposals) {
201
+ if (proposal.confidence < MIN_CONFIDENCE) {
210
202
  skipped++;
211
- log.info({ reason: "missing_title", scope_id: scope.id }, "memory.checkpoint.skip");
203
+ log.info({ reason: "low_confidence", confidence: proposal.confidence, scope: input.activeScope.slug }, "memory.checkpoint.skip");
212
204
  continue;
213
205
  }
214
- if (isDuplicateDecision(proposal, scope.id)) {
206
+ if (written >= MAX_WRITES_PER_CHECKPOINT) {
207
+ skipped++;
208
+ log.info({ reason: "cap_exceeded", scope: input.activeScope.slug }, "memory.checkpoint.skip");
209
+ continue;
210
+ }
211
+ if (!scope) {
212
+ skipped++;
213
+ log.info({ reason: "no_scope", scope: proposal.scope ?? input.activeScope.slug }, "memory.checkpoint.skip");
214
+ continue;
215
+ }
216
+ if (proposal.kind === "observation" && isDuplicateObservation(proposal.content, scope.id, pendingObservationContents)) {
215
217
  skipped++;
216
218
  log.info({ reason: "duplicate", scope_id: scope.id }, "memory.checkpoint.skip");
217
219
  continue;
218
220
  }
219
- const decision = recordDecision({
221
+ if (proposal.kind === "decision") {
222
+ if (!proposal.title) {
223
+ skipped++;
224
+ log.info({ reason: "missing_title", scope_id: scope.id }, "memory.checkpoint.skip");
225
+ continue;
226
+ }
227
+ if (isDuplicateDecision(proposal, scope.id)) {
228
+ skipped++;
229
+ log.info({ reason: "duplicate", scope_id: scope.id }, "memory.checkpoint.skip");
230
+ continue;
231
+ }
232
+ const decision = recordDecision({
233
+ scope_id: scope.id,
234
+ title: proposal.title,
235
+ rationale: proposal.content,
236
+ decided_at: proposal.decided_at || new Date().toISOString().slice(0, 10),
237
+ tier: "warm",
238
+ });
239
+ written++;
240
+ log.info({ kind: "decision", scope_id: scope.id, id: decision.id }, "memory.checkpoint.write");
241
+ continue;
242
+ }
243
+ const observation = recordObservation({
220
244
  scope_id: scope.id,
221
- title: proposal.title,
222
- rationale: proposal.content,
223
- decided_at: proposal.decided_at || new Date().toISOString().slice(0, 10),
245
+ content: proposal.content,
246
+ source: "checkpoint:orchestrator",
224
247
  tier: "warm",
248
+ confidence: proposal.confidence,
225
249
  });
250
+ pendingObservationContents.push(proposal.content);
226
251
  written++;
227
- log.info({ kind: "decision", scope_id: scope.id, id: decision.id }, "memory.checkpoint.write");
228
- continue;
252
+ log.info({ kind: "observation", scope_id: scope.id, id: observation.id }, "memory.checkpoint.write");
229
253
  }
230
- const observation = recordObservation({
231
- scope_id: scope.id,
232
- content: proposal.content,
233
- source: "checkpoint:orchestrator",
234
- tier: "warm",
235
- confidence: proposal.confidence,
236
- });
237
- pendingObservationContents.push(proposal.content);
238
- written++;
239
- log.info({ kind: "observation", scope_id: scope.id, id: observation.id }, "memory.checkpoint.write");
254
+ return { written, skipped, errors };
255
+ }
256
+ finally {
257
+ releaseScopeWriteLocks(lockedScopeIds);
240
258
  }
241
- return { written, skipped, errors };
242
259
  }
243
260
  catch (error) {
244
261
  const message = error instanceof Error ? error.message : String(error);
@@ -18,6 +18,24 @@ async function loadModules(cacheBust = `${Date.now()}-${Math.random()}`) {
18
18
  const checkpointPromptModule = await import(new URL(`./checkpoint-prompt.js?case=${cacheBust}`, import.meta.url).href);
19
19
  return { dbModule, memoryModule, checkpointModule, checkpointPromptModule };
20
20
  }
21
+ async function loadModulesWithWarnSpy(t, cacheBust) {
22
+ const warnings = [];
23
+ t.mock.module("../util/logger.js", {
24
+ namedExports: {
25
+ childLogger: () => ({
26
+ info: () => { },
27
+ warn: (...args) => {
28
+ warnings.push(args);
29
+ },
30
+ error: () => { },
31
+ }),
32
+ },
33
+ });
34
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
35
+ const memoryModule = await import(new URL(`./index.js?case=${cacheBust}`, import.meta.url).href);
36
+ const checkpointModule = await import(new URL(`./checkpoint.js?case=${cacheBust}`, import.meta.url).href);
37
+ return { dbModule, memoryModule, checkpointModule, warnings };
38
+ }
21
39
  function getFunction(module, name) {
22
40
  const value = module[name];
23
41
  assert.equal(typeof value, "function", `expected ${name} to be exported`);
@@ -157,8 +175,8 @@ test("runCheckpointExtraction writes high-confidence memories, skips low-confide
157
175
  assert.equal(listDecisions({ scope_id: chapterhouse.id }).some((row) => row.title === "Run checkpoint extraction asynchronously"), true);
158
176
  assert.equal(listObservations({ scope_id: chapterhouse.id, limit: 20 }).filter((row) => row.content === "Base new memory implementation branches from origin/main after dependent PRs merge.").length, 1);
159
177
  });
160
- test("runCheckpointExtraction handles malformed JSON responses without crashing", async () => {
161
- const { dbModule, memoryModule, checkpointModule } = await loadModules();
178
+ test("runCheckpointExtraction ignores malformed JSON responses and warns", async (t) => {
179
+ const { dbModule, memoryModule, checkpointModule, warnings } = await loadModulesWithWarnSpy(t, "malformed-json");
162
180
  dbModule.getDb();
163
181
  const getScope = getFunction(memoryModule, "getScope");
164
182
  const runCheckpointExtraction = getFunction(checkpointModule, "runCheckpointExtraction");
@@ -171,7 +189,9 @@ test("runCheckpointExtraction handles malformed JSON responses without crashing"
171
189
  callLLM: async () => "{ definitely-not-json",
172
190
  });
173
191
  assert.equal(result.written, 0);
174
- assert.ok(result.errors.length >= 1);
192
+ assert.equal(result.skipped, 0);
193
+ assert.deepEqual(result.errors, []);
194
+ assert.equal(warnings.length, 1);
175
195
  });
176
196
  test("runCheckpointExtraction prevents overlapping executions with the in-flight guard", async () => {
177
197
  const { dbModule, memoryModule, checkpointModule } = await loadModules();