chapterhouse 0.9.2 → 0.11.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 (121) hide show
  1. package/README.md +1 -1
  2. package/dist/api/auth.js +11 -1
  3. package/dist/api/auth.test.js +29 -0
  4. package/dist/api/errors.js +23 -0
  5. package/dist/api/route-coverage.test.js +61 -21
  6. package/dist/api/routes/agents.js +472 -0
  7. package/dist/api/routes/memory.js +299 -0
  8. package/dist/api/routes/projects.js +170 -0
  9. package/dist/api/routes/sessions.js +347 -0
  10. package/dist/api/routes/system.js +82 -0
  11. package/dist/api/routes/wiki.js +455 -0
  12. package/dist/api/routes/wiki.test.js +49 -0
  13. package/dist/api/send-json.js +16 -0
  14. package/dist/api/send-json.test.js +18 -0
  15. package/dist/api/server-runtime.js +45 -3
  16. package/dist/api/server.js +34 -1764
  17. package/dist/api/server.test.js +239 -8
  18. package/dist/api/sse-hub.js +37 -0
  19. package/dist/cli.js +1 -1
  20. package/dist/config.js +151 -58
  21. package/dist/config.test.js +29 -0
  22. package/dist/copilot/okr-mapper.js +2 -11
  23. package/dist/copilot/orchestrator.js +358 -352
  24. package/dist/copilot/orchestrator.test.js +139 -4
  25. package/dist/copilot/prompt-date.js +2 -1
  26. package/dist/copilot/session-manager.js +25 -23
  27. package/dist/copilot/session-manager.test.js +35 -1
  28. package/dist/copilot/standup.js +2 -2
  29. package/dist/copilot/task-event-log.js +7 -1
  30. package/dist/copilot/task-event-log.test.js +13 -0
  31. package/dist/copilot/tools/agent.js +608 -0
  32. package/dist/copilot/tools/index.js +19 -0
  33. package/dist/copilot/tools/memory.js +678 -0
  34. package/dist/copilot/tools/models.js +2 -0
  35. package/dist/copilot/tools/okr.js +171 -0
  36. package/dist/copilot/tools/wiki.js +333 -0
  37. package/dist/copilot/tools-deps.js +4 -0
  38. package/dist/copilot/tools.agent.test.js +10 -8
  39. package/dist/copilot/tools.inventory.test.js +76 -0
  40. package/dist/copilot/tools.js +1 -1780
  41. package/dist/copilot/tools.okr.test.js +31 -0
  42. package/dist/copilot/tools.wiki.test.js +6 -3
  43. package/dist/copilot/turn-event-log.js +31 -4
  44. package/dist/copilot/turn-event-log.test.js +24 -2
  45. package/dist/copilot/workiq-installer.test.js +2 -2
  46. package/dist/daemon-install.js +3 -2
  47. package/dist/daemon.js +9 -17
  48. package/dist/integrations/ado-client.js +90 -9
  49. package/dist/integrations/ado-client.test.js +56 -0
  50. package/dist/integrations/team-push.js +1 -0
  51. package/dist/integrations/team-push.test.js +6 -0
  52. package/dist/integrations/teams-notify.js +1 -0
  53. package/dist/integrations/teams-notify.test.js +5 -0
  54. package/dist/memory/active-scope.test.js +0 -1
  55. package/dist/memory/checkpoint.js +89 -72
  56. package/dist/memory/checkpoint.test.js +23 -3
  57. package/dist/memory/eot.js +87 -85
  58. package/dist/memory/eot.test.js +71 -3
  59. package/dist/memory/hooks.js +2 -4
  60. package/dist/memory/housekeeping-scheduler.js +1 -1
  61. package/dist/memory/housekeeping-scheduler.test.js +1 -2
  62. package/dist/memory/housekeeping.js +100 -3
  63. package/dist/memory/housekeeping.test.js +33 -2
  64. package/dist/memory/reflect.test.js +2 -0
  65. package/dist/memory/scope-lock.js +26 -0
  66. package/dist/memory/scope-lock.test.js +118 -0
  67. package/dist/memory/scopes.test.js +0 -1
  68. package/dist/mode-context.js +58 -5
  69. package/dist/mode-context.test.js +68 -0
  70. package/dist/paths.js +1 -0
  71. package/dist/setup.js +3 -2
  72. package/dist/shared/api-schemas.js +48 -5
  73. package/dist/store/connection.js +96 -0
  74. package/dist/store/db.js +5 -1498
  75. package/dist/store/db.test.js +182 -1
  76. package/dist/store/migrations.js +460 -0
  77. package/dist/store/repositories/memory.js +281 -0
  78. package/dist/store/repositories/okr.js +3 -0
  79. package/dist/store/repositories/projects.js +5 -0
  80. package/dist/store/repositories/sessions.js +284 -0
  81. package/dist/store/repositories/wiki.js +60 -0
  82. package/dist/store/schema.js +501 -0
  83. package/dist/util/logger.js +3 -2
  84. package/dist/wiki/consolidation.js +50 -9
  85. package/dist/wiki/consolidation.test.js +45 -0
  86. package/dist/wiki/frontmatter.js +43 -13
  87. package/dist/wiki/frontmatter.test.js +24 -0
  88. package/dist/wiki/fs.js +16 -4
  89. package/dist/wiki/fs.test.js +84 -0
  90. package/dist/wiki/index-manager.js +30 -2
  91. package/dist/wiki/index-manager.test.js +43 -12
  92. package/dist/wiki/ingest.js +1 -1
  93. package/dist/wiki/lock.js +11 -1
  94. package/dist/wiki/log-manager.js +2 -7
  95. package/dist/wiki/migrate.js +44 -17
  96. package/dist/wiki/project-registry.js +10 -5
  97. package/dist/wiki/project-registry.test.js +14 -0
  98. package/dist/wiki/scheduler.js +1 -1
  99. package/dist/wiki/seed-team-wiki.js +2 -1
  100. package/dist/wiki/team-sync.js +31 -6
  101. package/dist/wiki/team-sync.test.js +81 -0
  102. package/package.json +1 -1
  103. package/web/dist/assets/WikiEdit-EBVoY1Pk.js +30 -0
  104. package/web/dist/assets/WikiEdit-EBVoY1Pk.js.map +1 -0
  105. package/web/dist/assets/WikiGraph-BUbbABq-.js +2 -0
  106. package/web/dist/assets/WikiGraph-BUbbABq-.js.map +1 -0
  107. package/web/dist/assets/icon-acolyte-cream.svg +10 -0
  108. package/web/dist/assets/icon-acolyte-dark.svg +10 -0
  109. package/web/dist/assets/icon-acolyte-gold.svg +10 -0
  110. package/web/dist/assets/icon-acolyte-ibad.svg +10 -0
  111. package/web/dist/assets/icon-acolyte-lit.svg +10 -0
  112. package/web/dist/assets/icon-acolyte-mono.svg +10 -0
  113. package/web/dist/assets/icon-acolyte.png +0 -0
  114. package/web/dist/assets/icon-acolyte.svg +10 -0
  115. package/web/dist/assets/index-BGLL9pgM.css +10 -0
  116. package/web/dist/assets/index-KFX8UmOb.js +250 -0
  117. package/web/dist/assets/index-KFX8UmOb.js.map +1 -0
  118. package/web/dist/index.html +6 -4
  119. package/web/dist/assets/index-5kz9aRU9.css +0 -10
  120. package/web/dist/assets/index-iQrv3lQN.js +0 -286
  121. package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
@@ -0,0 +1,299 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod";
3
+ import { getActiveScope, setActiveScope } from "../../memory/active-scope.js";
4
+ import { recordDecision } from "../../memory/decisions.js";
5
+ import { upsertEntity } from "../../memory/entities.js";
6
+ import { handleGitCommitHook, handlePrMergeHook } from "../../memory/hooks.js";
7
+ import { getInboxItem, listPendingInboxItems, resolveInboxItem } from "../../memory/inbox.js";
8
+ import { recordObservation } from "../../memory/observations.js";
9
+ import { createScope, getScope, listScopes } from "../../memory/scopes.js";
10
+ import { ActiveMemoryScopeSchema, InboxRouteResponseSchema, MemoryEntriesSchema, MemoryHookResponseSchema, MemoryInboxSchema, MemoryRememberResponseSchema, MemoryScopeCreateResponseSchema, MemoryScopeListSchema, SetActiveScopeResponseSchema, } from "../../shared/api-schemas.js";
11
+ import { getDb } from "../../store/db.js";
12
+ import { childLogger } from "../../util/logger.js";
13
+ import { parseRequest } from "../errors.js";
14
+ import { sendJson } from "../send-json.js";
15
+ const log = childLogger("server");
16
+ const requiredString = (message) => z.string({ error: message }).trim().min(1, message);
17
+ const scopeCreateSchema = z.object({
18
+ slug: requiredString("Missing 'slug' in request body")
19
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Scope slug must be unique kebab-case"),
20
+ title: requiredString("Missing 'title' in request body"),
21
+ description: z.string().optional(),
22
+ }).strict();
23
+ const setActiveScopeSchema = z.object({
24
+ scope: z.string().nullable(),
25
+ });
26
+ const memoryEntriesQuerySchema = z.object({
27
+ store: z.enum(["observations", "decisions", "entities", "action_items"]).optional(),
28
+ tier: z.enum(["hot", "warm", "cold"]).optional(),
29
+ cursor: z.string().regex(/^\d+$/, "cursor must be a positive integer").optional(),
30
+ limit: z.coerce.number().int().min(1).max(100).optional(),
31
+ });
32
+ const memoryRememberSchema = z.object({
33
+ content: requiredString("Missing 'content' in request body"),
34
+ kind: z.enum(["observation", "decision"]).optional(),
35
+ entity_name: z.string().optional(),
36
+ entity_kind: z.string().optional(),
37
+ title: z.string().optional(),
38
+ decided_at: z.string().optional(),
39
+ tier: z.enum(["hot", "warm", "cold"]).optional(),
40
+ });
41
+ const inboxRouteSchema = z.object({
42
+ action: z.enum(["accept", "reject", "route"]),
43
+ reason: z.string().optional(),
44
+ target_scope: z.string().optional(),
45
+ });
46
+ const gitCommitHookSchema = z.object({
47
+ message: requiredString("Missing 'message' in request body"),
48
+ stat: z.string().optional(),
49
+ });
50
+ const prMergeHookSchema = z.object({
51
+ number: z.number({ error: "Missing or invalid 'number' in request body" }).int().positive(),
52
+ title: requiredString("Missing 'title' in request body"),
53
+ body: z.string().optional(),
54
+ files_changed: z.array(z.string()).optional(),
55
+ });
56
+ export function createMemoryRouter(options) {
57
+ const { authMiddleware } = options;
58
+ const router = Router();
59
+ router.get("/api/memory/active-scope", (_req, res) => {
60
+ const activeScope = getActiveScope();
61
+ if (!activeScope) {
62
+ sendJson(res, ActiveMemoryScopeSchema, null);
63
+ return;
64
+ }
65
+ sendJson(res, ActiveMemoryScopeSchema, {
66
+ slug: activeScope.slug,
67
+ title: activeScope.title,
68
+ });
69
+ });
70
+ router.post("/api/memory/active-scope", (req, res) => {
71
+ const body = parseRequest(setActiveScopeSchema, req.body ?? {});
72
+ try {
73
+ const scope = setActiveScope(body.scope);
74
+ sendJson(res, SetActiveScopeResponseSchema, { ok: true, scope: scope?.slug ?? null });
75
+ }
76
+ catch (err) {
77
+ res.status(404).json({ error: err instanceof Error ? err.message : String(err) });
78
+ }
79
+ });
80
+ router.get("/api/memory/scopes", (_req, res) => {
81
+ const db = getDb();
82
+ const activeScope = getActiveScope();
83
+ const scopes = listScopes();
84
+ const result = scopes.map((scope) => {
85
+ const counts = {
86
+ observations: db.prepare(`SELECT COUNT(*) AS count FROM mem_observations WHERE scope_id = ?`).get(scope.id).count,
87
+ decisions: db.prepare(`SELECT COUNT(*) AS count FROM mem_decisions WHERE scope_id = ?`).get(scope.id).count,
88
+ entities: db.prepare(`SELECT COUNT(*) AS count FROM mem_entities WHERE scope_id = ?`).get(scope.id).count,
89
+ action_items: db.prepare(`SELECT COUNT(*) AS count FROM mem_action_items WHERE scope_id = ?`).get(scope.id).count,
90
+ };
91
+ return {
92
+ slug: scope.slug,
93
+ title: scope.title,
94
+ description: scope.description,
95
+ active: activeScope?.slug === scope.slug,
96
+ counts,
97
+ };
98
+ });
99
+ sendJson(res, MemoryScopeListSchema, { scopes: result });
100
+ });
101
+ router.get("/api/memory/inbox", (_req, res) => {
102
+ const items = listPendingInboxItems();
103
+ const result = items.map((item) => ({
104
+ id: item.id,
105
+ scope_slug: item.scopeId
106
+ ? getDb().prepare(`SELECT slug FROM mem_scopes WHERE id = ?`).get(item.scopeId)?.slug ?? null
107
+ : null,
108
+ kind: item.kind,
109
+ payload: item.payload,
110
+ source_agent: item.sourceAgent,
111
+ created_at: item.createdAt,
112
+ }));
113
+ sendJson(res, MemoryInboxSchema, { items: result, total: result.length });
114
+ });
115
+ router.post("/api/memory/inbox/:id/route", (req, res) => {
116
+ const id = Number(req.params.id);
117
+ if (!Number.isInteger(id) || id <= 0) {
118
+ res.status(400).json({ error: "Invalid inbox item id" });
119
+ return;
120
+ }
121
+ const body = parseRequest(inboxRouteSchema, req.body ?? {});
122
+ const item = getInboxItem(id);
123
+ if (!item) {
124
+ res.status(404).json({ error: `Inbox item '${id}' not found` });
125
+ return;
126
+ }
127
+ if (item.status !== "pending") {
128
+ res.status(409).json({ error: `Inbox item '${id}' is already resolved` });
129
+ return;
130
+ }
131
+ const status = body.action === "accept" ? "accepted" : "rejected";
132
+ const reason = body.reason ?? (body.action === "accept" ? "Accepted via web UI" : "Rejected via web UI");
133
+ resolveInboxItem(id, status, reason);
134
+ log.info({ id, action: body.action }, "inbox item routed via web UI");
135
+ sendJson(res, InboxRouteResponseSchema, { ok: true });
136
+ });
137
+ router.get("/api/memory/:scope", (req, res) => {
138
+ const scopeSlug = String(req.params.scope);
139
+ const scope = getScope(scopeSlug);
140
+ if (!scope) {
141
+ res.status(404).json({ error: `Memory scope '${scopeSlug}' not found` });
142
+ return;
143
+ }
144
+ const query = parseRequest(memoryEntriesQuerySchema, req.query);
145
+ const store = query.store ?? "observations";
146
+ const tier = query.tier;
147
+ const cursor = query.cursor ? Number(query.cursor) : undefined;
148
+ const limit = query.limit ?? 100;
149
+ const db = getDb();
150
+ let entries;
151
+ let total;
152
+ let nextCursor;
153
+ if (store === "observations") {
154
+ const cursorClause = cursor ? "AND id < ?" : "";
155
+ const params = tier ? [scope.id, tier] : [scope.id];
156
+ const pageParams = cursor ? [...params, cursor, limit + 1] : [...params, limit + 1];
157
+ if (tier) {
158
+ entries = db.prepare(`SELECT id, content, source, tier, entity_id, created_at FROM mem_observations WHERE scope_id = ? AND tier = ? AND archived_at IS NULL ${cursorClause} ORDER BY id DESC LIMIT ?`).all(...pageParams);
159
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_observations WHERE scope_id = ? AND tier = ? AND archived_at IS NULL`).get(scope.id, tier).n;
160
+ }
161
+ else {
162
+ entries = db.prepare(`SELECT id, content, source, tier, entity_id, created_at FROM mem_observations WHERE scope_id = ? AND archived_at IS NULL ${cursorClause} ORDER BY id DESC LIMIT ?`).all(...pageParams);
163
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_observations WHERE scope_id = ? AND archived_at IS NULL`).get(scope.id).n;
164
+ }
165
+ }
166
+ else if (store === "decisions") {
167
+ const cursorClause = cursor ? "AND id < ?" : "";
168
+ const params = tier ? [scope.id, tier] : [scope.id];
169
+ const pageParams = cursor ? [...params, cursor, limit + 1] : [...params, limit + 1];
170
+ if (tier) {
171
+ entries = db.prepare(`SELECT id, title, rationale, tier, entity_id, decided_at, created_at FROM mem_decisions WHERE scope_id = ? AND tier = ? AND archived_at IS NULL ${cursorClause} ORDER BY id DESC LIMIT ?`).all(...pageParams);
172
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_decisions WHERE scope_id = ? AND tier = ? AND archived_at IS NULL`).get(scope.id, tier).n;
173
+ }
174
+ else {
175
+ entries = db.prepare(`SELECT id, title, rationale, tier, entity_id, decided_at, created_at FROM mem_decisions WHERE scope_id = ? AND archived_at IS NULL ${cursorClause} ORDER BY id DESC LIMIT ?`).all(...pageParams);
176
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_decisions WHERE scope_id = ? AND archived_at IS NULL`).get(scope.id).n;
177
+ }
178
+ }
179
+ else if (store === "entities") {
180
+ const cursorClause = cursor ? "AND id < ?" : "";
181
+ const params = tier ? [scope.id, tier] : [scope.id];
182
+ const pageParams = cursor ? [...params, cursor, limit + 1] : [...params, limit + 1];
183
+ if (tier) {
184
+ entries = db.prepare(`SELECT id, kind, name, summary, tier, created_at, updated_at FROM mem_entities WHERE scope_id = ? AND tier = ? ${cursorClause} ORDER BY id DESC LIMIT ?`).all(...pageParams);
185
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_entities WHERE scope_id = ? AND tier = ?`).get(scope.id, tier).n;
186
+ }
187
+ else {
188
+ entries = db.prepare(`SELECT id, kind, name, summary, tier, created_at, updated_at FROM mem_entities WHERE scope_id = ? ${cursorClause} ORDER BY id DESC LIMIT ?`).all(...pageParams);
189
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_entities WHERE scope_id = ?`).get(scope.id).n;
190
+ }
191
+ }
192
+ else {
193
+ const cursorClause = cursor ? "AND id < ?" : "";
194
+ const params = tier ? [scope.id, tier] : [scope.id];
195
+ const pageParams = cursor ? [...params, cursor, limit + 1] : [...params, limit + 1];
196
+ if (tier) {
197
+ entries = db.prepare(`SELECT id, title, detail, status, tier, due_at, entity_id, created_at FROM mem_action_items WHERE scope_id = ? AND tier = ? ${cursorClause} ORDER BY id DESC LIMIT ?`).all(...pageParams);
198
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_action_items WHERE scope_id = ? AND tier = ?`).get(scope.id, tier).n;
199
+ }
200
+ else {
201
+ entries = db.prepare(`SELECT id, title, detail, status, tier, due_at, entity_id, created_at FROM mem_action_items WHERE scope_id = ? ${cursorClause} ORDER BY id DESC LIMIT ?`).all(...pageParams);
202
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_action_items WHERE scope_id = ?`).get(scope.id).n;
203
+ }
204
+ }
205
+ if (entries.length > limit) {
206
+ const extra = entries.pop();
207
+ const last = entries[entries.length - 1];
208
+ nextCursor = String(last?.id ?? extra?.id ?? "");
209
+ }
210
+ sendJson(res, MemoryEntriesSchema, { entries: entries, total, ...(nextCursor ? { nextCursor } : {}) });
211
+ });
212
+ router.post("/api/memory/:scope/remember", (req, res) => {
213
+ const scopeSlug = String(req.params.scope);
214
+ const scope = getScope(scopeSlug);
215
+ if (!scope) {
216
+ res.status(404).json({ error: `Memory scope '${scopeSlug}' not found` });
217
+ return;
218
+ }
219
+ const body = parseRequest(memoryRememberSchema, req.body ?? {});
220
+ if (body.entity_name && !body.entity_kind) {
221
+ res.status(400).json({ error: "entity_kind is required when entity_name is provided" });
222
+ return;
223
+ }
224
+ const kind = body.kind ?? "observation";
225
+ const entity = body.entity_name
226
+ ? upsertEntity({
227
+ scope_id: scope.id,
228
+ kind: body.entity_kind,
229
+ name: body.entity_name,
230
+ tier: body.tier ?? "warm",
231
+ })
232
+ : undefined;
233
+ if (kind === "decision") {
234
+ if (!body.title) {
235
+ res.status(400).json({ error: "title is required when kind='decision'" });
236
+ return;
237
+ }
238
+ const decision = recordDecision({
239
+ scope_id: scope.id,
240
+ entity_id: entity?.id,
241
+ title: body.title,
242
+ rationale: body.content,
243
+ decided_at: body.decided_at ?? new Date().toISOString().slice(0, 10),
244
+ tier: body.tier ?? "warm",
245
+ });
246
+ log.info({ id: decision.id, scope: scopeSlug, kind }, "memory written via web UI");
247
+ sendJson(res, MemoryRememberResponseSchema, { ok: true, id: String(decision.id) });
248
+ return;
249
+ }
250
+ const observation = recordObservation({
251
+ scope_id: scope.id,
252
+ entity_id: entity?.id,
253
+ content: body.content,
254
+ source: "agent:web-ui",
255
+ tier: body.tier ?? "warm",
256
+ });
257
+ log.info({ id: observation.id, scope: scopeSlug, kind }, "memory written via web UI");
258
+ sendJson(res, MemoryRememberResponseSchema, { ok: true, id: String(observation.id) });
259
+ });
260
+ router.post("/api/memory/hooks/git-commit", authMiddleware, (req, res, next) => {
261
+ const body = parseRequest(gitCommitHookSchema, req.body ?? {});
262
+ handleGitCommitHook({ message: body.message, stat: body.stat })
263
+ .then((result) => sendJson(res, MemoryHookResponseSchema, { ok: true, observation_id: result.observation_id }))
264
+ .catch(next);
265
+ });
266
+ router.post("/api/memory/hooks/pr-merge", authMiddleware, (req, res, next) => {
267
+ const body = parseRequest(prMergeHookSchema, req.body ?? {});
268
+ handlePrMergeHook({
269
+ number: body.number,
270
+ title: body.title,
271
+ body: body.body,
272
+ files_changed: body.files_changed,
273
+ })
274
+ .then((result) => sendJson(res, MemoryHookResponseSchema, { ok: true, observation_id: result.observation_id }))
275
+ .catch(next);
276
+ });
277
+ router.post("/api/scopes", (req, res) => {
278
+ const body = parseRequest(scopeCreateSchema, req.body ?? {});
279
+ if (getScope(body.slug)) {
280
+ res.status(409).json({ error: `Memory scope '${body.slug}' already exists` });
281
+ return;
282
+ }
283
+ const scope = createScope({
284
+ slug: body.slug,
285
+ title: body.title,
286
+ description: body.description ?? "",
287
+ keywords: [body.slug],
288
+ });
289
+ res.status(201);
290
+ sendJson(res, MemoryScopeCreateResponseSchema, {
291
+ slug: scope.slug,
292
+ title: scope.title,
293
+ description: scope.description,
294
+ active: scope.active,
295
+ });
296
+ });
297
+ return router;
298
+ }
299
+ //# sourceMappingURL=memory.js.map
@@ -0,0 +1,170 @@
1
+ import { Router } from "express";
2
+ import { existsSync, statSync } from "fs";
3
+ import { z } from "zod";
4
+ import { ProjectDeleteResponseSchema, ProjectDetailSchema, ProjectListSchema } from "../../shared/api-schemas.js";
5
+ import { ensureWikiStructure, pageExists, writePage, deletePage } from "../../wiki/fs.js";
6
+ import { withWikiWrite } from "../../wiki/lock.js";
7
+ import { loadRegistry, normalizeProjectPath, saveRegistry } from "../../wiki/project-registry.js";
8
+ import { getProjectRulesPath, listTopLevelSoftRules, loadProjectRules, loadProjectRuleSummary, renderInitialProjectRulesPage, saveProjectRulesHardFields, saveProjectRulesSoftRules, } from "../../wiki/project-rules.js";
9
+ import { BadRequestError, ForbiddenError, NotFoundError, parseRequest } from "../errors.js";
10
+ import { sendJson } from "../send-json.js";
11
+ const requiredString = (message) => z.string({ error: message }).trim().min(1, message);
12
+ const projectCreateSchema = z.object({
13
+ slug: requiredString("Missing 'slug' in request body")
14
+ .regex(/^[a-z0-9][a-z0-9-]*$/, "Project slug must be a lowercase slug"),
15
+ cwd: requiredString("Missing 'cwd' in request body")
16
+ .refine((value) => value.startsWith("/"), "Project cwd must be an absolute path"),
17
+ }).strict();
18
+ const projectHardRulesSchema = z.object({
19
+ hardRules: z.object({
20
+ auto_pr: z.boolean({ error: "hardRules.auto_pr must be a boolean" }),
21
+ require_worktree: z.boolean({ error: "hardRules.require_worktree must be a boolean" }),
22
+ pr_draft_default: z.boolean({ error: "hardRules.pr_draft_default must be a boolean" }),
23
+ default_branch: requiredString("hardRules.default_branch must be a non-empty string"),
24
+ commit_co_author: requiredString("hardRules.commit_co_author must be a non-empty string"),
25
+ test_command: z.string({ error: "hardRules.test_command must be a string" }),
26
+ build_command: z.string({ error: "hardRules.build_command must be a string" }),
27
+ lint_command: z.string({ error: "hardRules.lint_command must be a string" }),
28
+ require_clean_worktree: z.boolean({ error: "hardRules.require_clean_worktree must be a boolean" }),
29
+ }).strict(),
30
+ }).strict();
31
+ const projectSoftRulesSchema = z.object({
32
+ softRules: z.array(requiredString("softRules entries must be non-empty strings")),
33
+ }).strict();
34
+ function assertProjectCwdDirectory(cwd) {
35
+ if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
36
+ throw new BadRequestError("Project cwd must exist and be a directory");
37
+ }
38
+ }
39
+ function createProjectDetailPayload(slug, cwd) {
40
+ const rules = loadProjectRules(slug);
41
+ if (!rules.found) {
42
+ return {
43
+ slug,
44
+ cwd,
45
+ rulesFound: false,
46
+ hardRules: null,
47
+ softRules: [],
48
+ };
49
+ }
50
+ return {
51
+ slug,
52
+ cwd,
53
+ rulesFound: true,
54
+ hardRules: rules.hard,
55
+ softRules: listTopLevelSoftRules(rules.soft),
56
+ };
57
+ }
58
+ export function createProjectsRouter(options) {
59
+ const { modeContext } = options;
60
+ const router = Router();
61
+ function assertProjectRegistryMutationAllowed(req) {
62
+ if (modeContext.isTeam() && req.user?.role !== "team-lead") {
63
+ throw new ForbiddenError("Forbidden");
64
+ }
65
+ }
66
+ router.get("/api/projects", (_req, res) => {
67
+ ensureWikiStructure();
68
+ const projects = Object.entries(loadRegistry())
69
+ .sort(([left], [right]) => left.localeCompare(right))
70
+ .map(([slug, cwd]) => {
71
+ const summary = loadProjectRuleSummary(slug);
72
+ return {
73
+ slug,
74
+ cwd,
75
+ hardRuleCount: summary.hardRuleCount,
76
+ softRuleCount: summary.softRuleCount,
77
+ };
78
+ });
79
+ sendJson(res, ProjectListSchema, projects);
80
+ });
81
+ router.get("/api/projects/:slug", (req, res) => {
82
+ ensureWikiStructure();
83
+ const slugParam = req.params.slug;
84
+ const slug = Array.isArray(slugParam) ? (slugParam[0] ?? "") : (slugParam ?? "");
85
+ const cwd = loadRegistry()[slug];
86
+ if (!cwd) {
87
+ res.status(404).json({ error: "Project not found" });
88
+ return;
89
+ }
90
+ sendJson(res, ProjectDetailSchema, createProjectDetailPayload(slug, cwd));
91
+ });
92
+ router.post("/api/projects", async (req, res) => {
93
+ ensureWikiStructure();
94
+ assertProjectRegistryMutationAllowed(req);
95
+ const { slug, cwd } = parseRequest(projectCreateSchema, req.body ?? {});
96
+ const normalizedCwd = normalizeProjectPath(cwd);
97
+ const rulesPath = getProjectRulesPath(slug);
98
+ await withWikiWrite(() => {
99
+ const registry = loadRegistry();
100
+ if (registry[slug]) {
101
+ throw new BadRequestError(`Project '${slug}' already exists`);
102
+ }
103
+ if (pageExists(rulesPath)) {
104
+ throw new BadRequestError(`Project rules page '${rulesPath}' already exists`);
105
+ }
106
+ assertProjectCwdDirectory(normalizedCwd);
107
+ saveRegistry({
108
+ ...registry,
109
+ [slug]: normalizedCwd,
110
+ });
111
+ writePage(rulesPath, renderInitialProjectRulesPage(slug));
112
+ });
113
+ res.status(201);
114
+ sendJson(res, ProjectDetailSchema, createProjectDetailPayload(slug, normalizedCwd));
115
+ });
116
+ router.delete("/api/projects/:slug", async (req, res) => {
117
+ ensureWikiStructure();
118
+ assertProjectRegistryMutationAllowed(req);
119
+ const slugParam = req.params.slug;
120
+ const slug = Array.isArray(slugParam) ? (slugParam[0] ?? "") : (slugParam ?? "");
121
+ const registry = loadRegistry();
122
+ if (!registry[slug]) {
123
+ throw new NotFoundError("Project not found");
124
+ }
125
+ const rulesPath = getProjectRulesPath(slug);
126
+ await withWikiWrite(() => {
127
+ const nextRegistry = { ...loadRegistry() };
128
+ delete nextRegistry[slug];
129
+ saveRegistry(nextRegistry);
130
+ deletePage(rulesPath);
131
+ });
132
+ sendJson(res, ProjectDeleteResponseSchema, { ok: true, slug });
133
+ });
134
+ router.put("/api/projects/:slug/rules/hard", async (req, res) => {
135
+ ensureWikiStructure();
136
+ const slugParam = req.params.slug;
137
+ const slug = Array.isArray(slugParam) ? (slugParam[0] ?? "") : (slugParam ?? "");
138
+ const cwd = loadRegistry()[slug];
139
+ if (!cwd) {
140
+ throw new NotFoundError("Project not found");
141
+ }
142
+ if (!pageExists(getProjectRulesPath(slug))) {
143
+ throw new NotFoundError("Project rules not found");
144
+ }
145
+ const { hardRules } = parseRequest(projectHardRulesSchema, req.body ?? {});
146
+ await withWikiWrite(() => {
147
+ saveProjectRulesHardFields(slug, hardRules);
148
+ });
149
+ sendJson(res, ProjectDetailSchema, createProjectDetailPayload(slug, cwd));
150
+ });
151
+ router.put("/api/projects/:slug/rules/soft", async (req, res) => {
152
+ ensureWikiStructure();
153
+ const slugParam = req.params.slug;
154
+ const slug = Array.isArray(slugParam) ? (slugParam[0] ?? "") : (slugParam ?? "");
155
+ const cwd = loadRegistry()[slug];
156
+ if (!cwd) {
157
+ throw new NotFoundError("Project not found");
158
+ }
159
+ if (!pageExists(getProjectRulesPath(slug))) {
160
+ throw new NotFoundError("Project rules not found");
161
+ }
162
+ const { softRules } = parseRequest(projectSoftRulesSchema, req.body ?? {});
163
+ await withWikiWrite(() => {
164
+ saveProjectRulesSoftRules(slug, softRules);
165
+ });
166
+ sendJson(res, ProjectDetailSchema, createProjectDetailPayload(slug, cwd));
167
+ });
168
+ return router;
169
+ }
170
+ //# sourceMappingURL=projects.js.map