hippo-memory 1.15.0 → 1.16.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 (97) hide show
  1. package/README.md +862 -861
  2. package/dist/audit.d.ts +1 -1
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js.map +1 -1
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +1243 -3
  7. package/dist/cli.js.map +1 -1
  8. package/dist/customer-notes.d.ts +95 -0
  9. package/dist/customer-notes.d.ts.map +1 -0
  10. package/dist/customer-notes.js +296 -0
  11. package/dist/customer-notes.js.map +1 -0
  12. package/dist/db.d.ts.map +1 -1
  13. package/dist/db.js +731 -1
  14. package/dist/db.js.map +1 -1
  15. package/dist/graph-extract.d.ts +39 -0
  16. package/dist/graph-extract.d.ts.map +1 -0
  17. package/dist/graph-extract.js +141 -0
  18. package/dist/graph-extract.js.map +1 -0
  19. package/dist/graph-recall.d.ts +41 -0
  20. package/dist/graph-recall.d.ts.map +1 -0
  21. package/dist/graph-recall.js +246 -0
  22. package/dist/graph-recall.js.map +1 -0
  23. package/dist/graph.d.ts +137 -0
  24. package/dist/graph.d.ts.map +1 -0
  25. package/dist/graph.js +433 -0
  26. package/dist/graph.js.map +1 -0
  27. package/dist/incidents.d.ts +100 -0
  28. package/dist/incidents.d.ts.map +1 -0
  29. package/dist/incidents.js +322 -0
  30. package/dist/incidents.js.map +1 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +1 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/memory.d.ts +6 -0
  36. package/dist/memory.d.ts.map +1 -1
  37. package/dist/memory.js +6 -0
  38. package/dist/memory.js.map +1 -1
  39. package/dist/policies.d.ts +149 -0
  40. package/dist/policies.d.ts.map +1 -0
  41. package/dist/policies.js +380 -0
  42. package/dist/policies.js.map +1 -0
  43. package/dist/processes.d.ts +104 -0
  44. package/dist/processes.d.ts.map +1 -0
  45. package/dist/processes.js +330 -0
  46. package/dist/processes.js.map +1 -0
  47. package/dist/project-briefs.d.ts +126 -0
  48. package/dist/project-briefs.d.ts.map +1 -0
  49. package/dist/project-briefs.js +453 -0
  50. package/dist/project-briefs.js.map +1 -0
  51. package/dist/search.d.ts +7 -0
  52. package/dist/search.d.ts.map +1 -1
  53. package/dist/search.js.map +1 -1
  54. package/dist/server.d.ts.map +1 -1
  55. package/dist/server.js +1028 -16
  56. package/dist/server.js.map +1 -1
  57. package/dist/skills.d.ts +98 -0
  58. package/dist/skills.d.ts.map +1 -0
  59. package/dist/skills.js +339 -0
  60. package/dist/skills.js.map +1 -0
  61. package/dist/src/audit.js.map +1 -1
  62. package/dist/src/cli.js +1243 -3
  63. package/dist/src/cli.js.map +1 -1
  64. package/dist/src/customer-notes.js +296 -0
  65. package/dist/src/customer-notes.js.map +1 -0
  66. package/dist/src/db.js +731 -1
  67. package/dist/src/db.js.map +1 -1
  68. package/dist/src/graph-extract.js +141 -0
  69. package/dist/src/graph-extract.js.map +1 -0
  70. package/dist/src/graph-recall.js +246 -0
  71. package/dist/src/graph-recall.js.map +1 -0
  72. package/dist/src/graph.js +433 -0
  73. package/dist/src/graph.js.map +1 -0
  74. package/dist/src/incidents.js +322 -0
  75. package/dist/src/incidents.js.map +1 -0
  76. package/dist/src/index.js +1 -0
  77. package/dist/src/index.js.map +1 -1
  78. package/dist/src/memory.js +6 -0
  79. package/dist/src/memory.js.map +1 -1
  80. package/dist/src/policies.js +380 -0
  81. package/dist/src/policies.js.map +1 -0
  82. package/dist/src/processes.js +330 -0
  83. package/dist/src/processes.js.map +1 -0
  84. package/dist/src/project-briefs.js +453 -0
  85. package/dist/src/project-briefs.js.map +1 -0
  86. package/dist/src/search.js.map +1 -1
  87. package/dist/src/server.js +1028 -16
  88. package/dist/src/server.js.map +1 -1
  89. package/dist/src/skills.js +339 -0
  90. package/dist/src/skills.js.map +1 -0
  91. package/dist/src/version.js +1 -1
  92. package/dist/version.d.ts +1 -1
  93. package/dist/version.js +1 -1
  94. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  95. package/extensions/openclaw-plugin/package.json +1 -1
  96. package/openclaw.plugin.json +1 -1
  97. package/package.json +2 -2
package/dist/server.js CHANGED
@@ -22,6 +22,12 @@ import { createRateLimiter } from './rate-limit.js';
22
22
  import { remember, recall, RecallContractError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, adminActor, } from './api.js';
23
23
  import { savePrediction, closePrediction, loadPredictionById, loadPredictionsByClass, loadOpenPredictions, computePredictionBaserate, VALID_CLOSURE_STATES, } from './predictions.js';
24
24
  import { saveDecision, closeDecision, loadDecisionById, loadDecisions, VALID_DECISION_STATES, } from './decisions.js';
25
+ import { saveIncident, resolveIncident, closeIncident, loadIncidentById, loadIncidents, VALID_INCIDENT_STATES, } from './incidents.js';
26
+ import { saveProcess, closeProcess, loadProcessById, loadProcesses, VALID_PROCESS_STATES, } from './processes.js';
27
+ import { savePolicy, closePolicy, loadPolicyById, loadPolicies, loadPoliciesAsOf, VALID_POLICY_STATES, } from './policies.js';
28
+ import { saveSkill, closeSkill, loadSkillById, loadSkills, exportSkills, VALID_SKILL_STATES, } from './skills.js';
29
+ import { saveProjectBrief, closeProjectBrief, loadProjectBriefById, loadProjectBriefs, assembleBriefFromReceipts, refreshBrief, VALID_BRIEF_STATES, } from './project-briefs.js';
30
+ import { saveCustomerNote, closeCustomerNote, loadCustomerNoteById, loadCustomerNotes, VALID_NOTE_STATES, } from './customer-notes.js';
25
31
  import { handleMcpRequest } from './mcp/server.js';
26
32
  import { verifySlackSignature } from './connectors/slack/signature.js';
27
33
  import { isSlackEventEnvelope, isSlackMessageEvent } from './connectors/slack/types.js';
@@ -80,11 +86,85 @@ const VALID_AUDIT_OPS = new Set([
80
86
  'decision_create', // E2 decision first-class object — emitted by saveDecision
81
87
  'decision_supersede', // E2 — emitted by saveDecision when --supersedes resolves to an active decision row
82
88
  'decision_close', // E2 — emitted by closeDecision
89
+ 'incident_open', // E2 incident first-class object — emitted by saveIncident
90
+ 'incident_resolve', // E2 — emitted by resolveIncident (open -> resolved)
91
+ 'incident_close', // E2 — emitted by closeIncident (open|resolved -> closed)
92
+ 'process_create', // E2 process first-class object — emitted by saveProcess
93
+ 'process_supersede', // E2 — emitted by saveProcess on a supersession
94
+ 'process_close', // E2 — emitted by closeProcess
95
+ 'policy_create', // E2 policy first-class object — emitted by savePolicy
96
+ 'policy_supersede', // E2 — emitted by savePolicy on a supersession
97
+ 'policy_close', // E2 — emitted by closePolicy
98
+ 'skill_create', // E2 skill first-class object — emitted by saveSkill
99
+ 'skill_supersede', // E2 — emitted by saveSkill on a supersession
100
+ 'skill_close', // E2 — emitted by closeSkill
101
+ 'project_brief_create', // E2 project_brief first-class object — emitted by saveProjectBrief
102
+ 'project_brief_supersede', // E2 — emitted by saveProjectBrief on a supersession (incl. refresh)
103
+ 'project_brief_close', // E2 — emitted by closeProjectBrief
104
+ 'customer_note_create', // E2 customer_note first-class object — emitted by saveCustomerNote
105
+ 'customer_note_supersede', // E2 — emitted by saveCustomerNote on a supersession
106
+ 'customer_note_close', // E2 — emitted by closeCustomerNote
83
107
  ]);
84
108
  // Cap on GET /v1/audit?limit=. Matches docs/api.md (when written) and is large
85
109
  // enough to dump a small deployment's full audit log without paginating, but
86
110
  // small enough that a malicious client can't ask for the world.
87
111
  const MAX_AUDIT_LIMIT = 10000;
112
+ // HTTP-boundary validation for a process `steps` body (untrusted). Returns the
113
+ // step strings (saveProcess re-validates + trims, this is the fail-fast 400
114
+ // gate). Caps mirror src/processes.ts MAX_PROCESS_STEPS / MAX_PROCESS_STEP_LEN.
115
+ function validateProcessStepsBody(raw) {
116
+ if (raw === undefined || raw === null)
117
+ return [];
118
+ if (!Array.isArray(raw)) {
119
+ throw new HttpError(400, 'steps must be an array of strings');
120
+ }
121
+ if (raw.length > 200) {
122
+ throw new HttpError(400, 'steps exceeds 200-step cap');
123
+ }
124
+ for (const item of raw) {
125
+ if (typeof item !== 'string') {
126
+ throw new HttpError(400, 'each step must be a string');
127
+ }
128
+ if (item.trim().length === 0) {
129
+ throw new HttpError(400, 'a step is empty');
130
+ }
131
+ if (item.length > 2000) {
132
+ throw new HttpError(400, 'a step exceeds the 2000-character cap');
133
+ }
134
+ }
135
+ return raw;
136
+ }
137
+ // HTTP-boundary check for an optional policy date field (validFrom/validTo).
138
+ // Type + length only; savePolicy/loadPoliciesAsOf normalize + format-validate the
139
+ // value (an unparseable date throws there -> mapped to 400). 64-char cap bounds a
140
+ // junk string before it reaches the Date parser.
141
+ function optionalDateField(raw, label) {
142
+ if (raw === undefined || raw === null)
143
+ return undefined;
144
+ if (typeof raw !== 'string') {
145
+ throw new HttpError(400, `${label} must be a string`);
146
+ }
147
+ if (raw.length > 64) {
148
+ throw new HttpError(400, `${label} exceeds 64-character cap`);
149
+ }
150
+ return raw;
151
+ }
152
+ // Parse a `?limit=` query param for the E2 list routes. Defaults to 100; requires
153
+ // a positive INTEGER <= 1000. Number.isInteger rejects fractional values like
154
+ // "1.5" that Number.isFinite would pass but SQLite `LIMIT ?` rejects with a
155
+ // datatype mismatch (a 500). Shared across the decision/incident/process/policy
156
+ // list routes so the guard cannot drift (codex review 2026-05-30 P2: fractional
157
+ // limit reached SQLite on the policy route; the same latent hole existed in the
158
+ // sibling routes this was copied from).
159
+ function parseListLimit(limitRaw) {
160
+ if (limitRaw === null)
161
+ return 100;
162
+ const limit = Number(limitRaw);
163
+ if (!Number.isInteger(limit) || limit <= 0 || limit > 1000) {
164
+ throw new HttpError(400, 'limit must be a positive integer <= 1000');
165
+ }
166
+ return limit;
167
+ }
88
168
  const VALID_KINDS = new Set([
89
169
  'raw',
90
170
  'distilled',
@@ -1029,14 +1109,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1029
1109
  if (method === 'GET' && path === '/v1/predictions') {
1030
1110
  const classTag = query.get('class') ?? undefined;
1031
1111
  const status = query.get('status') ?? 'all';
1032
- const limitRaw = query.get('limit');
1033
- let limit = 100;
1034
- if (limitRaw !== null) {
1035
- limit = Number(limitRaw);
1036
- if (!Number.isFinite(limit) || limit <= 0 || limit > 1000) {
1037
- throw new HttpError(400, 'limit must be a positive integer <= 1000');
1038
- }
1039
- }
1112
+ const limit = parseListLimit(query.get('limit'));
1040
1113
  const ctx = buildContextWithAuth(req, opts.hippoRoot);
1041
1114
  let predictions;
1042
1115
  if (status === 'all') {
@@ -1199,14 +1272,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1199
1272
  }
1200
1273
  if (method === 'GET' && path === '/v1/decisions') {
1201
1274
  const status = query.get('status') ?? 'all';
1202
- const limitRaw = query.get('limit');
1203
- let limit = 100;
1204
- if (limitRaw !== null) {
1205
- limit = Number(limitRaw);
1206
- if (!Number.isFinite(limit) || limit <= 0 || limit > 1000) {
1207
- throw new HttpError(400, 'limit must be a positive integer <= 1000');
1208
- }
1209
- }
1275
+ const limit = parseListLimit(query.get('limit'));
1210
1276
  const ctx = buildContextWithAuth(req, opts.hippoRoot);
1211
1277
  let decisions;
1212
1278
  if (status === 'all') {
@@ -1298,6 +1364,952 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1298
1364
  sendJson(res, 200, { decision });
1299
1365
  return;
1300
1366
  }
1367
+ // ── incidents (E2 first-class object) ──
1368
+ //
1369
+ // 5 routes: POST /v1/incidents (open; body text + context + linkedMemoryIds[]),
1370
+ // GET /v1/incidents (list, status filter), GET /v1/incidents/:id (show),
1371
+ // POST /v1/incidents/:id/resolve (open -> resolved; body resolutionText),
1372
+ // POST /v1/incidents/:id/close (open|resolved -> closed). Bearer-authed +
1373
+ // tenant-scoped via buildContextWithAuth. status validated against
1374
+ // VALID_INCIDENT_STATES. DoS caps: text 4096, context 4096, resolutionText
1375
+ // 4096 (v1.11.4 pattern). Mirrors /v1/decisions; lifecycle is
1376
+ // open->resolved->closed (no supersede), so linkedMemoryIds replaces
1377
+ // supersedesDecisionId on create.
1378
+ if (method === 'POST' && path === '/v1/incidents') {
1379
+ const body = await parseJsonBody(req);
1380
+ const text = body['text'];
1381
+ if (typeof text !== 'string' || text.length === 0) {
1382
+ throw new HttpError(400, 'text is required (non-empty string)');
1383
+ }
1384
+ if (text.length > 4096) {
1385
+ throw new HttpError(400, 'text exceeds 4096-character cap');
1386
+ }
1387
+ const contextRaw = body['context'];
1388
+ let context;
1389
+ if (contextRaw !== undefined && contextRaw !== null) {
1390
+ if (typeof contextRaw !== 'string') {
1391
+ throw new HttpError(400, 'context must be a string');
1392
+ }
1393
+ if (contextRaw.length > 4096) {
1394
+ throw new HttpError(400, 'context exceeds 4096-character cap');
1395
+ }
1396
+ context = contextRaw;
1397
+ }
1398
+ const linkedRaw = body['linkedMemoryIds'];
1399
+ let linkedMemoryIds;
1400
+ if (linkedRaw !== undefined && linkedRaw !== null) {
1401
+ if (!Array.isArray(linkedRaw)) {
1402
+ throw new HttpError(400, 'linkedMemoryIds must be an array of memory ids');
1403
+ }
1404
+ if (linkedRaw.length > 256) {
1405
+ throw new HttpError(400, 'linkedMemoryIds exceeds 256-item cap');
1406
+ }
1407
+ for (const item of linkedRaw) {
1408
+ if (typeof item !== 'string' || item.length === 0 || item.length > 4096) {
1409
+ throw new HttpError(400, 'each linkedMemoryIds entry must be a non-empty string <= 4096 chars');
1410
+ }
1411
+ }
1412
+ linkedMemoryIds = linkedRaw;
1413
+ }
1414
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1415
+ try {
1416
+ const incident = saveIncident(opts.hippoRoot, ctx.tenantId, {
1417
+ incidentText: text,
1418
+ context,
1419
+ linkedMemoryIds,
1420
+ }, ctx.actor.subject);
1421
+ sendJson(res, 201, { incident });
1422
+ }
1423
+ catch (e) {
1424
+ const msg = e.message;
1425
+ if (msg.includes('not found')) {
1426
+ throw new HttpError(409, msg);
1427
+ }
1428
+ throw e;
1429
+ }
1430
+ return;
1431
+ }
1432
+ if (method === 'GET' && path === '/v1/incidents') {
1433
+ const status = query.get('status') ?? 'all';
1434
+ const limit = parseListLimit(query.get('limit'));
1435
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1436
+ let incidents;
1437
+ if (status === 'all') {
1438
+ incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, { limit });
1439
+ }
1440
+ else {
1441
+ if (!VALID_INCIDENT_STATES.has(status)) {
1442
+ throw new HttpError(400, `status must be one of: open | resolved | closed | all (got "${status}")`);
1443
+ }
1444
+ incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, {
1445
+ status: status,
1446
+ limit,
1447
+ });
1448
+ }
1449
+ sendJson(res, 200, { incidents });
1450
+ return;
1451
+ }
1452
+ const incidentResolveMatch = path.match(/^\/v1\/incidents\/(\d+)\/resolve$/);
1453
+ if (method === 'POST' && incidentResolveMatch) {
1454
+ const id = parseInt(incidentResolveMatch[1], 10);
1455
+ const body = await parseJsonBody(req);
1456
+ const resolutionText = body['resolutionText'];
1457
+ if (typeof resolutionText !== 'string' || resolutionText.trim().length === 0) {
1458
+ throw new HttpError(400, 'resolutionText is required (non-empty string)');
1459
+ }
1460
+ if (resolutionText.length > 4096) {
1461
+ throw new HttpError(400, 'resolutionText exceeds 4096-character cap');
1462
+ }
1463
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1464
+ try {
1465
+ const incident = resolveIncident(opts.hippoRoot, ctx.tenantId, id, resolutionText, ctx.actor.subject);
1466
+ sendJson(res, 200, { incident });
1467
+ }
1468
+ catch (e) {
1469
+ const msg = e.message;
1470
+ if (msg.includes('not found')) {
1471
+ throw new HttpError(404, msg);
1472
+ }
1473
+ if (msg.includes('not open')) {
1474
+ throw new HttpError(409, msg);
1475
+ }
1476
+ throw e;
1477
+ }
1478
+ return;
1479
+ }
1480
+ const incidentCloseMatch = path.match(/^\/v1\/incidents\/(\d+)\/close$/);
1481
+ if (method === 'POST' && incidentCloseMatch) {
1482
+ const id = parseInt(incidentCloseMatch[1], 10);
1483
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1484
+ try {
1485
+ const incident = closeIncident(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1486
+ sendJson(res, 200, { incident });
1487
+ }
1488
+ catch (e) {
1489
+ const msg = e.message;
1490
+ if (msg.includes('not found')) {
1491
+ throw new HttpError(404, msg);
1492
+ }
1493
+ if (msg.includes('already closed')) {
1494
+ throw new HttpError(409, msg);
1495
+ }
1496
+ throw e;
1497
+ }
1498
+ return;
1499
+ }
1500
+ const incidentByIdMatch = path.match(/^\/v1\/incidents\/(\d+)$/);
1501
+ if (method === 'GET' && incidentByIdMatch) {
1502
+ const id = parseInt(incidentByIdMatch[1], 10);
1503
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1504
+ const incident = loadIncidentById(opts.hippoRoot, ctx.tenantId, id);
1505
+ if (!incident) {
1506
+ throw new HttpError(404, `incident ${id} not found`);
1507
+ }
1508
+ sendJson(res, 200, { incident });
1509
+ return;
1510
+ }
1511
+ // ── processes (E2 first-class object) ──
1512
+ //
1513
+ // 5 routes: POST /v1/processes (new; body processName + steps[] + description),
1514
+ // GET /v1/processes (list, status filter), GET /v1/processes/:id (show),
1515
+ // POST /v1/processes/:id/supersede (active -> superseded by a new version; body
1516
+ // steps[] + changeSummary + description; reuses the predecessor's name),
1517
+ // POST /v1/processes/:id/close (active -> closed). Bearer-authed + tenant-scoped
1518
+ // via buildContextWithAuth. status validated against VALID_PROCESS_STATES. DoS
1519
+ // caps: processName/description/changeSummary 4096, steps 200x2000
1520
+ // (validateProcessStepsBody). Mirrors /v1/decisions; the delta lifecycle is the
1521
+ // decision supersede path.
1522
+ if (method === 'POST' && path === '/v1/processes') {
1523
+ const body = await parseJsonBody(req);
1524
+ const processName = body['processName'];
1525
+ if (typeof processName !== 'string' || processName.trim().length === 0) {
1526
+ throw new HttpError(400, 'processName is required (non-empty string)');
1527
+ }
1528
+ if (processName.length > 4096) {
1529
+ throw new HttpError(400, 'processName exceeds 4096-character cap');
1530
+ }
1531
+ const steps = validateProcessStepsBody(body['steps']);
1532
+ const descriptionRaw = body['description'];
1533
+ let description;
1534
+ if (descriptionRaw !== undefined && descriptionRaw !== null) {
1535
+ if (typeof descriptionRaw !== 'string') {
1536
+ throw new HttpError(400, 'description must be a string');
1537
+ }
1538
+ if (descriptionRaw.length > 4096) {
1539
+ throw new HttpError(400, 'description exceeds 4096-character cap');
1540
+ }
1541
+ description = descriptionRaw;
1542
+ }
1543
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1544
+ const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
1545
+ processName,
1546
+ steps,
1547
+ description,
1548
+ }, ctx.actor.subject);
1549
+ sendJson(res, 201, { process });
1550
+ return;
1551
+ }
1552
+ if (method === 'GET' && path === '/v1/processes') {
1553
+ const status = query.get('status') ?? 'all';
1554
+ const limit = parseListLimit(query.get('limit'));
1555
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1556
+ let processes;
1557
+ if (status === 'all') {
1558
+ processes = loadProcesses(opts.hippoRoot, ctx.tenantId, { limit });
1559
+ }
1560
+ else {
1561
+ if (!VALID_PROCESS_STATES.has(status)) {
1562
+ throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1563
+ }
1564
+ processes = loadProcesses(opts.hippoRoot, ctx.tenantId, {
1565
+ status: status,
1566
+ limit,
1567
+ });
1568
+ }
1569
+ sendJson(res, 200, { processes });
1570
+ return;
1571
+ }
1572
+ const processSupersedeMatch = path.match(/^\/v1\/processes\/(\d+)\/supersede$/);
1573
+ if (method === 'POST' && processSupersedeMatch) {
1574
+ const id = parseInt(processSupersedeMatch[1], 10);
1575
+ const body = await parseJsonBody(req);
1576
+ const steps = validateProcessStepsBody(body['steps']);
1577
+ if (steps.length === 0) {
1578
+ throw new HttpError(400, 'steps is required (at least one step) for a supersession');
1579
+ }
1580
+ const changeRaw = body['changeSummary'];
1581
+ let changeSummary;
1582
+ if (changeRaw !== undefined && changeRaw !== null) {
1583
+ if (typeof changeRaw !== 'string') {
1584
+ throw new HttpError(400, 'changeSummary must be a string');
1585
+ }
1586
+ if (changeRaw.length > 4096) {
1587
+ throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
1588
+ }
1589
+ changeSummary = changeRaw;
1590
+ }
1591
+ const descRaw = body['description'];
1592
+ let description;
1593
+ if (descRaw !== undefined && descRaw !== null) {
1594
+ if (typeof descRaw !== 'string') {
1595
+ throw new HttpError(400, 'description must be a string');
1596
+ }
1597
+ if (descRaw.length > 4096) {
1598
+ throw new HttpError(400, 'description exceeds 4096-character cap');
1599
+ }
1600
+ description = descRaw;
1601
+ }
1602
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1603
+ // A supersession is a new version of the SAME process: reuse the
1604
+ // predecessor's name. 404 if the target does not exist; saveProcess's
1605
+ // in-SAVEPOINT preflight is the authoritative active-state check (409).
1606
+ const existing = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
1607
+ if (!existing) {
1608
+ throw new HttpError(404, `process ${id} not found`);
1609
+ }
1610
+ try {
1611
+ const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
1612
+ processName: existing.processName,
1613
+ steps,
1614
+ description,
1615
+ changeSummary,
1616
+ supersedesProcessId: id,
1617
+ }, ctx.actor.subject);
1618
+ sendJson(res, 200, { process });
1619
+ }
1620
+ catch (e) {
1621
+ const msg = e.message;
1622
+ if (msg.includes('not found')) {
1623
+ throw new HttpError(404, msg);
1624
+ }
1625
+ if (msg.includes('not active') || msg.includes('could not be superseded')) {
1626
+ throw new HttpError(409, msg);
1627
+ }
1628
+ throw e;
1629
+ }
1630
+ return;
1631
+ }
1632
+ const processCloseMatch = path.match(/^\/v1\/processes\/(\d+)\/close$/);
1633
+ if (method === 'POST' && processCloseMatch) {
1634
+ const id = parseInt(processCloseMatch[1], 10);
1635
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1636
+ try {
1637
+ const process = closeProcess(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1638
+ sendJson(res, 200, { process });
1639
+ }
1640
+ catch (e) {
1641
+ const msg = e.message;
1642
+ if (msg.includes('not found')) {
1643
+ throw new HttpError(404, msg);
1644
+ }
1645
+ if (msg.includes('not active')) {
1646
+ throw new HttpError(409, msg);
1647
+ }
1648
+ throw e;
1649
+ }
1650
+ return;
1651
+ }
1652
+ const processByIdMatch = path.match(/^\/v1\/processes\/(\d+)$/);
1653
+ if (method === 'GET' && processByIdMatch) {
1654
+ const id = parseInt(processByIdMatch[1], 10);
1655
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1656
+ const process = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
1657
+ if (!process) {
1658
+ throw new HttpError(404, `process ${id} not found`);
1659
+ }
1660
+ sendJson(res, 200, { process });
1661
+ return;
1662
+ }
1663
+ // ── policies (E2 first-class object, bi-temporal-first) ──
1664
+ //
1665
+ // 6 routes: POST /v1/policies (new; processName-style body policyName +
1666
+ // policyText + validFrom? + validTo?), GET /v1/policies (list, status filter),
1667
+ // GET /v1/policies/asof (date + optional name; the bi-temporal as-of query;
1668
+ // placed BEFORE the /:id GET so the literal 'asof' is matched first), GET
1669
+ // /v1/policies/:id, POST /v1/policies/:id/supersede, POST /v1/policies/:id/close.
1670
+ // Date inputs are normalized + range-validated in the store; an invalid/inverted
1671
+ // date throws -> 400. DoS caps: policyName/policyText/changeSummary 4096.
1672
+ if (method === 'POST' && path === '/v1/policies') {
1673
+ const body = await parseJsonBody(req);
1674
+ const policyName = body['policyName'];
1675
+ if (typeof policyName !== 'string' || policyName.trim().length === 0) {
1676
+ throw new HttpError(400, 'policyName is required (non-empty string)');
1677
+ }
1678
+ if (policyName.length > 4096) {
1679
+ throw new HttpError(400, 'policyName exceeds 4096-character cap');
1680
+ }
1681
+ const policyText = body['policyText'];
1682
+ if (typeof policyText !== 'string' || policyText.trim().length === 0) {
1683
+ throw new HttpError(400, 'policyText is required (non-empty string)');
1684
+ }
1685
+ if (policyText.length > 4096) {
1686
+ throw new HttpError(400, 'policyText exceeds 4096-character cap');
1687
+ }
1688
+ const validFrom = optionalDateField(body['validFrom'], 'validFrom');
1689
+ const validTo = optionalDateField(body['validTo'], 'validTo');
1690
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1691
+ try {
1692
+ const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1693
+ policyName,
1694
+ policyText,
1695
+ validFrom,
1696
+ validTo,
1697
+ }, ctx.actor.subject);
1698
+ sendJson(res, 201, { policy });
1699
+ }
1700
+ catch (e) {
1701
+ // savePolicy throws on invalid/inverted dates (validation) -> 400.
1702
+ throw new HttpError(400, e.message);
1703
+ }
1704
+ return;
1705
+ }
1706
+ if (method === 'GET' && path === '/v1/policies') {
1707
+ const status = query.get('status') ?? 'all';
1708
+ const limit = parseListLimit(query.get('limit'));
1709
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1710
+ let policies;
1711
+ if (status === 'all') {
1712
+ policies = loadPolicies(opts.hippoRoot, ctx.tenantId, { limit });
1713
+ }
1714
+ else {
1715
+ if (!VALID_POLICY_STATES.has(status)) {
1716
+ throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1717
+ }
1718
+ policies = loadPolicies(opts.hippoRoot, ctx.tenantId, {
1719
+ status: status,
1720
+ limit,
1721
+ });
1722
+ }
1723
+ sendJson(res, 200, { policies });
1724
+ return;
1725
+ }
1726
+ // The as-of query: must precede the /:id GET (literal 'asof' is non-numeric so
1727
+ // the /(\d+)/ route would not match it, but order it first for clarity).
1728
+ if (method === 'GET' && path === '/v1/policies/asof') {
1729
+ const date = query.get('date');
1730
+ if (date === null || date.length === 0) {
1731
+ throw new HttpError(400, 'date is required (ISO-8601 valid-time)');
1732
+ }
1733
+ const name = query.get('name') ?? undefined;
1734
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1735
+ try {
1736
+ const policies = loadPoliciesAsOf(opts.hippoRoot, ctx.tenantId, date, { name });
1737
+ sendJson(res, 200, { policies });
1738
+ }
1739
+ catch (e) {
1740
+ throw new HttpError(400, e.message);
1741
+ }
1742
+ return;
1743
+ }
1744
+ const policySupersedeMatch = path.match(/^\/v1\/policies\/(\d+)\/supersede$/);
1745
+ if (method === 'POST' && policySupersedeMatch) {
1746
+ const id = parseInt(policySupersedeMatch[1], 10);
1747
+ const body = await parseJsonBody(req);
1748
+ const policyText = body['policyText'];
1749
+ if (typeof policyText !== 'string' || policyText.trim().length === 0) {
1750
+ throw new HttpError(400, 'policyText is required (non-empty string)');
1751
+ }
1752
+ if (policyText.length > 4096) {
1753
+ throw new HttpError(400, 'policyText exceeds 4096-character cap');
1754
+ }
1755
+ const validFrom = optionalDateField(body['validFrom'], 'validFrom');
1756
+ const validTo = optionalDateField(body['validTo'], 'validTo');
1757
+ const changeRaw = body['changeSummary'];
1758
+ let changeSummary;
1759
+ if (changeRaw !== undefined && changeRaw !== null) {
1760
+ if (typeof changeRaw !== 'string') {
1761
+ throw new HttpError(400, 'changeSummary must be a string');
1762
+ }
1763
+ if (changeRaw.length > 4096) {
1764
+ throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
1765
+ }
1766
+ changeSummary = changeRaw;
1767
+ }
1768
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1769
+ const existing = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
1770
+ if (!existing) {
1771
+ throw new HttpError(404, `policy ${id} not found`);
1772
+ }
1773
+ try {
1774
+ const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1775
+ policyName: existing.policyName,
1776
+ policyText,
1777
+ validFrom,
1778
+ validTo,
1779
+ changeSummary,
1780
+ supersedesPolicyId: id,
1781
+ }, ctx.actor.subject);
1782
+ sendJson(res, 200, { policy });
1783
+ }
1784
+ catch (e) {
1785
+ const msg = e.message;
1786
+ if (msg.includes('not found')) {
1787
+ throw new HttpError(404, msg);
1788
+ }
1789
+ if (msg.includes('not active') || msg.includes('could not be superseded')) {
1790
+ throw new HttpError(409, msg);
1791
+ }
1792
+ // invalid/inverted date or missing field -> validation.
1793
+ throw new HttpError(400, msg);
1794
+ }
1795
+ return;
1796
+ }
1797
+ const policyCloseMatch = path.match(/^\/v1\/policies\/(\d+)\/close$/);
1798
+ if (method === 'POST' && policyCloseMatch) {
1799
+ const id = parseInt(policyCloseMatch[1], 10);
1800
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1801
+ try {
1802
+ const policy = closePolicy(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1803
+ sendJson(res, 200, { policy });
1804
+ }
1805
+ catch (e) {
1806
+ const msg = e.message;
1807
+ if (msg.includes('not found')) {
1808
+ throw new HttpError(404, msg);
1809
+ }
1810
+ if (msg.includes('not active')) {
1811
+ throw new HttpError(409, msg);
1812
+ }
1813
+ throw e;
1814
+ }
1815
+ return;
1816
+ }
1817
+ const policyByIdMatch = path.match(/^\/v1\/policies\/(\d+)$/);
1818
+ if (method === 'GET' && policyByIdMatch) {
1819
+ const id = parseInt(policyByIdMatch[1], 10);
1820
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1821
+ const policy = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
1822
+ if (!policy) {
1823
+ throw new HttpError(404, `policy ${id} not found`);
1824
+ }
1825
+ sendJson(res, 200, { policy });
1826
+ return;
1827
+ }
1828
+ // ── skills (E2 first-class object, executable/exportable) ──
1829
+ //
1830
+ // 6 routes: POST /v1/skills (new; body skillName + instructions + trigger?),
1831
+ // GET /v1/skills (list, status filter; shared parseListLimit), GET
1832
+ // /v1/skills/export (renders ACTIVE skills as an AGENTS.md/CLAUDE.md markdown
1833
+ // block -> {markdown}; literal 'export' is non-numeric so the /:id (\d+) route
1834
+ // cannot capture it, but it is ordered first regardless), GET /v1/skills/:id,
1835
+ // POST /v1/skills/:id/supersede, POST /v1/skills/:id/close. DoS caps:
1836
+ // skillName 256, instructions 8192, trigger 1024, changeSummary 4096. The store
1837
+ // validates + throws; the boundary maps validation -> 400, not-found -> 404,
1838
+ // not-active -> 409. Mirrors /v1/processes; "executable" = exportable
1839
+ // instruction (no code exec).
1840
+ if (method === 'POST' && path === '/v1/skills') {
1841
+ const body = await parseJsonBody(req);
1842
+ const skillName = body['skillName'];
1843
+ if (typeof skillName !== 'string' || skillName.trim().length === 0) {
1844
+ throw new HttpError(400, 'skillName is required (non-empty string)');
1845
+ }
1846
+ if (skillName.length > 256) {
1847
+ throw new HttpError(400, 'skillName exceeds 256-character cap');
1848
+ }
1849
+ const instructions = body['instructions'];
1850
+ if (typeof instructions !== 'string' || instructions.trim().length === 0) {
1851
+ throw new HttpError(400, 'instructions are required (non-empty string)');
1852
+ }
1853
+ if (instructions.length > 8192) {
1854
+ throw new HttpError(400, 'instructions exceed 8192-character cap');
1855
+ }
1856
+ const triggerRaw = body['trigger'];
1857
+ let trigger;
1858
+ if (triggerRaw !== undefined && triggerRaw !== null) {
1859
+ if (typeof triggerRaw !== 'string') {
1860
+ throw new HttpError(400, 'trigger must be a string');
1861
+ }
1862
+ if (triggerRaw.length > 1024) {
1863
+ throw new HttpError(400, 'trigger exceeds 1024-character cap');
1864
+ }
1865
+ trigger = triggerRaw;
1866
+ }
1867
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1868
+ try {
1869
+ const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
1870
+ skillName,
1871
+ instructions,
1872
+ trigger,
1873
+ }, ctx.actor.subject);
1874
+ sendJson(res, 201, { skill });
1875
+ }
1876
+ catch (e) {
1877
+ // saveSkill throws on validation (single-line name etc.) -> 400.
1878
+ throw new HttpError(400, e.message);
1879
+ }
1880
+ return;
1881
+ }
1882
+ if (method === 'GET' && path === '/v1/skills') {
1883
+ const status = query.get('status') ?? 'all';
1884
+ const limit = parseListLimit(query.get('limit'));
1885
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1886
+ let skills;
1887
+ if (status === 'all') {
1888
+ skills = loadSkills(opts.hippoRoot, ctx.tenantId, { limit });
1889
+ }
1890
+ else {
1891
+ if (!VALID_SKILL_STATES.has(status)) {
1892
+ throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1893
+ }
1894
+ skills = loadSkills(opts.hippoRoot, ctx.tenantId, {
1895
+ status: status,
1896
+ limit,
1897
+ });
1898
+ }
1899
+ sendJson(res, 200, { skills });
1900
+ return;
1901
+ }
1902
+ // The export renderer: must precede the /:id GET (literal 'export' is
1903
+ // non-numeric so the /(\d+)/ route would not match it, but order it first).
1904
+ if (method === 'GET' && path === '/v1/skills/export') {
1905
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1906
+ const markdown = exportSkills(opts.hippoRoot, ctx.tenantId);
1907
+ sendJson(res, 200, { markdown });
1908
+ return;
1909
+ }
1910
+ const skillSupersedeMatch = path.match(/^\/v1\/skills\/(\d+)\/supersede$/);
1911
+ if (method === 'POST' && skillSupersedeMatch) {
1912
+ const id = parseInt(skillSupersedeMatch[1], 10);
1913
+ const body = await parseJsonBody(req);
1914
+ const instructions = body['instructions'];
1915
+ if (typeof instructions !== 'string' || instructions.trim().length === 0) {
1916
+ throw new HttpError(400, 'instructions are required (non-empty string)');
1917
+ }
1918
+ if (instructions.length > 8192) {
1919
+ throw new HttpError(400, 'instructions exceed 8192-character cap');
1920
+ }
1921
+ const triggerRaw = body['trigger'];
1922
+ let trigger;
1923
+ if (triggerRaw !== undefined && triggerRaw !== null) {
1924
+ if (typeof triggerRaw !== 'string') {
1925
+ throw new HttpError(400, 'trigger must be a string');
1926
+ }
1927
+ if (triggerRaw.length > 1024) {
1928
+ throw new HttpError(400, 'trigger exceeds 1024-character cap');
1929
+ }
1930
+ trigger = triggerRaw;
1931
+ }
1932
+ const changeRaw = body['changeSummary'];
1933
+ let changeSummary;
1934
+ if (changeRaw !== undefined && changeRaw !== null) {
1935
+ if (typeof changeRaw !== 'string') {
1936
+ throw new HttpError(400, 'changeSummary must be a string');
1937
+ }
1938
+ if (changeRaw.length > 4096) {
1939
+ throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
1940
+ }
1941
+ changeSummary = changeRaw;
1942
+ }
1943
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1944
+ const existing = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
1945
+ if (!existing) {
1946
+ throw new HttpError(404, `skill ${id} not found`);
1947
+ }
1948
+ try {
1949
+ const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
1950
+ skillName: existing.skillName,
1951
+ instructions,
1952
+ trigger,
1953
+ changeSummary,
1954
+ supersedesSkillId: id,
1955
+ }, ctx.actor.subject);
1956
+ sendJson(res, 200, { skill });
1957
+ }
1958
+ catch (e) {
1959
+ const msg = e.message;
1960
+ if (msg.includes('not found')) {
1961
+ throw new HttpError(404, msg);
1962
+ }
1963
+ if (msg.includes('not active') || msg.includes('could not be superseded')) {
1964
+ throw new HttpError(409, msg);
1965
+ }
1966
+ throw new HttpError(400, msg);
1967
+ }
1968
+ return;
1969
+ }
1970
+ const skillCloseMatch = path.match(/^\/v1\/skills\/(\d+)\/close$/);
1971
+ if (method === 'POST' && skillCloseMatch) {
1972
+ const id = parseInt(skillCloseMatch[1], 10);
1973
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1974
+ try {
1975
+ const skill = closeSkill(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1976
+ sendJson(res, 200, { skill });
1977
+ }
1978
+ catch (e) {
1979
+ const msg = e.message;
1980
+ if (msg.includes('not found')) {
1981
+ throw new HttpError(404, msg);
1982
+ }
1983
+ if (msg.includes('not active')) {
1984
+ throw new HttpError(409, msg);
1985
+ }
1986
+ throw e;
1987
+ }
1988
+ return;
1989
+ }
1990
+ const skillByIdMatch = path.match(/^\/v1\/skills\/(\d+)$/);
1991
+ if (method === 'GET' && skillByIdMatch) {
1992
+ const id = parseInt(skillByIdMatch[1], 10);
1993
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1994
+ const skill = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
1995
+ if (!skill) {
1996
+ throw new HttpError(404, `skill ${id} not found`);
1997
+ }
1998
+ sendJson(res, 200, { skill });
1999
+ return;
2000
+ }
2001
+ // ── E2 project_brief routes ──
2002
+ //
2003
+ // 6 routes: POST /v1/project-briefs (new; body repo + summary), GET
2004
+ // /v1/project-briefs (list; status + repo filter; shared parseListLimit), POST
2005
+ // /v1/project-briefs/refresh (body {repo, dryRun?} -> auto-assemble the brief
2006
+ // from the repo's receipts; dryRun returns {markdown} without writing; ordered
2007
+ // before /:id), GET /v1/project-briefs/:id, POST /v1/project-briefs/:id/supersede,
2008
+ // POST /v1/project-briefs/:id/close. DoS caps: repo 256, summary 8192,
2009
+ // changeSummary 4096. The store validates + throws; the boundary maps validation
2010
+ // -> 400, not-found -> 404, not-active -> 409. Mirrors /v1/skills.
2011
+ if (method === 'POST' && path === '/v1/project-briefs') {
2012
+ const body = await parseJsonBody(req);
2013
+ const repo = body['repo'];
2014
+ if (typeof repo !== 'string' || repo.trim().length === 0) {
2015
+ throw new HttpError(400, 'repo is required (non-empty string)');
2016
+ }
2017
+ if (repo.length > 256) {
2018
+ throw new HttpError(400, 'repo exceeds 256-character cap');
2019
+ }
2020
+ const summary = body['summary'];
2021
+ if (typeof summary !== 'string' || summary.trim().length === 0) {
2022
+ throw new HttpError(400, 'summary is required (non-empty string)');
2023
+ }
2024
+ if (summary.length > 8192) {
2025
+ throw new HttpError(400, 'summary exceeds 8192-character cap');
2026
+ }
2027
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
2028
+ try {
2029
+ const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2030
+ repo,
2031
+ summary,
2032
+ }, ctx.actor.subject);
2033
+ sendJson(res, 201, { brief });
2034
+ }
2035
+ catch (e) {
2036
+ // saveProjectBrief throws on validation (single-line repo etc.) -> 400.
2037
+ throw new HttpError(400, e.message);
2038
+ }
2039
+ return;
2040
+ }
2041
+ if (method === 'GET' && path === '/v1/project-briefs') {
2042
+ const status = query.get('status') ?? 'all';
2043
+ const repoFilter = query.get('repo');
2044
+ const limit = parseListLimit(query.get('limit'));
2045
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
2046
+ const listOpts = { limit };
2047
+ if (repoFilter !== null && repoFilter.trim().length > 0) {
2048
+ listOpts.repo = repoFilter.trim();
2049
+ }
2050
+ if (status !== 'all') {
2051
+ if (!VALID_BRIEF_STATES.has(status)) {
2052
+ throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
2053
+ }
2054
+ listOpts.status = status;
2055
+ }
2056
+ const briefs = loadProjectBriefs(opts.hippoRoot, ctx.tenantId, listOpts);
2057
+ sendJson(res, 200, { briefs });
2058
+ return;
2059
+ }
2060
+ // The refresh op: must precede the /:id routes (literal 'refresh' is non-numeric
2061
+ // so the /(\d+)/ routes would not match it, but order it first).
2062
+ if (method === 'POST' && path === '/v1/project-briefs/refresh') {
2063
+ const body = await parseJsonBody(req);
2064
+ const repo = body['repo'];
2065
+ if (typeof repo !== 'string' || repo.trim().length === 0) {
2066
+ throw new HttpError(400, 'repo is required (non-empty string)');
2067
+ }
2068
+ if (repo.length > 256) {
2069
+ throw new HttpError(400, 'repo exceeds 256-character cap');
2070
+ }
2071
+ const dryRun = body['dryRun'] === true;
2072
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
2073
+ try {
2074
+ if (dryRun) {
2075
+ const { markdown, receiptCount } = assembleBriefFromReceipts(opts.hippoRoot, ctx.tenantId, repo);
2076
+ sendJson(res, 200, { markdown, receiptCount });
2077
+ return;
2078
+ }
2079
+ const brief = refreshBrief(opts.hippoRoot, ctx.tenantId, repo, ctx.actor.subject);
2080
+ sendJson(res, 200, { brief });
2081
+ }
2082
+ catch (e) {
2083
+ // A refresh race (the active brief is closed/superseded between
2084
+ // loadActiveBriefForRepo and the supersede CAS) is a state conflict, not a
2085
+ // validation error — map it to 409 like the explicit supersede route
2086
+ // (codex-review 2026-05-30, P3).
2087
+ const msg = e.message;
2088
+ if (msg.includes('not found')) {
2089
+ throw new HttpError(404, msg);
2090
+ }
2091
+ if (msg.includes('not active') || msg.includes('could not be superseded')) {
2092
+ throw new HttpError(409, msg);
2093
+ }
2094
+ throw new HttpError(400, msg);
2095
+ }
2096
+ return;
2097
+ }
2098
+ const briefSupersedeMatch = path.match(/^\/v1\/project-briefs\/(\d+)\/supersede$/);
2099
+ if (method === 'POST' && briefSupersedeMatch) {
2100
+ const id = parseInt(briefSupersedeMatch[1], 10);
2101
+ const body = await parseJsonBody(req);
2102
+ const summary = body['summary'];
2103
+ if (typeof summary !== 'string' || summary.trim().length === 0) {
2104
+ throw new HttpError(400, 'summary is required (non-empty string)');
2105
+ }
2106
+ if (summary.length > 8192) {
2107
+ throw new HttpError(400, 'summary exceeds 8192-character cap');
2108
+ }
2109
+ const changeRaw = body['changeSummary'];
2110
+ let changeSummary;
2111
+ if (changeRaw !== undefined && changeRaw !== null) {
2112
+ if (typeof changeRaw !== 'string') {
2113
+ throw new HttpError(400, 'changeSummary must be a string');
2114
+ }
2115
+ if (changeRaw.length > 4096) {
2116
+ throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
2117
+ }
2118
+ changeSummary = changeRaw;
2119
+ }
2120
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
2121
+ const existing = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
2122
+ if (!existing) {
2123
+ throw new HttpError(404, `project brief ${id} not found`);
2124
+ }
2125
+ try {
2126
+ const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2127
+ repo: existing.repo,
2128
+ summary,
2129
+ changeSummary,
2130
+ supersedesBriefId: id,
2131
+ }, ctx.actor.subject);
2132
+ sendJson(res, 200, { brief });
2133
+ }
2134
+ catch (e) {
2135
+ const msg = e.message;
2136
+ if (msg.includes('not found')) {
2137
+ throw new HttpError(404, msg);
2138
+ }
2139
+ if (msg.includes('not active') || msg.includes('could not be superseded')) {
2140
+ throw new HttpError(409, msg);
2141
+ }
2142
+ throw new HttpError(400, msg);
2143
+ }
2144
+ return;
2145
+ }
2146
+ const briefCloseMatch = path.match(/^\/v1\/project-briefs\/(\d+)\/close$/);
2147
+ if (method === 'POST' && briefCloseMatch) {
2148
+ const id = parseInt(briefCloseMatch[1], 10);
2149
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
2150
+ try {
2151
+ const brief = closeProjectBrief(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2152
+ sendJson(res, 200, { brief });
2153
+ }
2154
+ catch (e) {
2155
+ const msg = e.message;
2156
+ if (msg.includes('not found')) {
2157
+ throw new HttpError(404, msg);
2158
+ }
2159
+ if (msg.includes('not active')) {
2160
+ throw new HttpError(409, msg);
2161
+ }
2162
+ throw e;
2163
+ }
2164
+ return;
2165
+ }
2166
+ const briefByIdMatch = path.match(/^\/v1\/project-briefs\/(\d+)$/);
2167
+ if (method === 'GET' && briefByIdMatch) {
2168
+ const id = parseInt(briefByIdMatch[1], 10);
2169
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
2170
+ const brief = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
2171
+ if (!brief) {
2172
+ throw new HttpError(404, `project brief ${id} not found`);
2173
+ }
2174
+ sendJson(res, 200, { brief });
2175
+ return;
2176
+ }
2177
+ // ── E2 customer_note routes ──
2178
+ //
2179
+ // 5 routes (no assembler/refresh): POST /v1/customer-notes (new; body customer +
2180
+ // note), GET /v1/customer-notes (list; status + customer filter; shared
2181
+ // parseListLimit), GET /v1/customer-notes/:id, POST /v1/customer-notes/:id/supersede,
2182
+ // POST /v1/customer-notes/:id/close. DoS caps: customer 256, note 8192,
2183
+ // changeSummary 4096. The store validates + throws; the boundary maps validation ->
2184
+ // 400, not-found -> 404, not-active -> 409. Mirrors /v1/project-briefs.
2185
+ if (method === 'POST' && path === '/v1/customer-notes') {
2186
+ const body = await parseJsonBody(req);
2187
+ const customer = body['customer'];
2188
+ if (typeof customer !== 'string' || customer.trim().length === 0) {
2189
+ throw new HttpError(400, 'customer is required (non-empty string)');
2190
+ }
2191
+ if (customer.length > 256) {
2192
+ throw new HttpError(400, 'customer exceeds 256-character cap');
2193
+ }
2194
+ const note = body['note'];
2195
+ if (typeof note !== 'string' || note.trim().length === 0) {
2196
+ throw new HttpError(400, 'note is required (non-empty string)');
2197
+ }
2198
+ if (note.length > 8192) {
2199
+ throw new HttpError(400, 'note exceeds 8192-character cap');
2200
+ }
2201
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
2202
+ try {
2203
+ const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2204
+ customer,
2205
+ note,
2206
+ }, ctx.actor.subject);
2207
+ sendJson(res, 201, { note: customerNote });
2208
+ }
2209
+ catch (e) {
2210
+ // saveCustomerNote throws on validation (single-line customer etc.) -> 400.
2211
+ throw new HttpError(400, e.message);
2212
+ }
2213
+ return;
2214
+ }
2215
+ if (method === 'GET' && path === '/v1/customer-notes') {
2216
+ const status = query.get('status') ?? 'all';
2217
+ const customerFilter = query.get('customer');
2218
+ const limit = parseListLimit(query.get('limit'));
2219
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
2220
+ const listOpts = { limit };
2221
+ if (customerFilter !== null && customerFilter.trim().length > 0) {
2222
+ listOpts.customer = customerFilter.trim();
2223
+ }
2224
+ if (status !== 'all') {
2225
+ if (!VALID_NOTE_STATES.has(status)) {
2226
+ throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
2227
+ }
2228
+ listOpts.status = status;
2229
+ }
2230
+ const notes = loadCustomerNotes(opts.hippoRoot, ctx.tenantId, listOpts);
2231
+ sendJson(res, 200, { notes });
2232
+ return;
2233
+ }
2234
+ const noteSupersedeMatch = path.match(/^\/v1\/customer-notes\/(\d+)\/supersede$/);
2235
+ if (method === 'POST' && noteSupersedeMatch) {
2236
+ const id = parseInt(noteSupersedeMatch[1], 10);
2237
+ const body = await parseJsonBody(req);
2238
+ const note = body['note'];
2239
+ if (typeof note !== 'string' || note.trim().length === 0) {
2240
+ throw new HttpError(400, 'note is required (non-empty string)');
2241
+ }
2242
+ if (note.length > 8192) {
2243
+ throw new HttpError(400, 'note exceeds 8192-character cap');
2244
+ }
2245
+ const changeRaw = body['changeSummary'];
2246
+ let changeSummary;
2247
+ if (changeRaw !== undefined && changeRaw !== null) {
2248
+ if (typeof changeRaw !== 'string') {
2249
+ throw new HttpError(400, 'changeSummary must be a string');
2250
+ }
2251
+ if (changeRaw.length > 4096) {
2252
+ throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
2253
+ }
2254
+ changeSummary = changeRaw;
2255
+ }
2256
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
2257
+ const existing = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
2258
+ if (!existing) {
2259
+ throw new HttpError(404, `customer note ${id} not found`);
2260
+ }
2261
+ try {
2262
+ const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2263
+ customer: existing.customer,
2264
+ note,
2265
+ changeSummary,
2266
+ supersedesNoteId: id,
2267
+ }, ctx.actor.subject);
2268
+ sendJson(res, 200, { note: customerNote });
2269
+ }
2270
+ catch (e) {
2271
+ const msg = e.message;
2272
+ if (msg.includes('not found')) {
2273
+ throw new HttpError(404, msg);
2274
+ }
2275
+ if (msg.includes('not active') || msg.includes('could not be superseded')) {
2276
+ throw new HttpError(409, msg);
2277
+ }
2278
+ throw new HttpError(400, msg);
2279
+ }
2280
+ return;
2281
+ }
2282
+ const noteCloseMatch = path.match(/^\/v1\/customer-notes\/(\d+)\/close$/);
2283
+ if (method === 'POST' && noteCloseMatch) {
2284
+ const id = parseInt(noteCloseMatch[1], 10);
2285
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
2286
+ try {
2287
+ const customerNote = closeCustomerNote(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2288
+ sendJson(res, 200, { note: customerNote });
2289
+ }
2290
+ catch (e) {
2291
+ const msg = e.message;
2292
+ if (msg.includes('not found')) {
2293
+ throw new HttpError(404, msg);
2294
+ }
2295
+ if (msg.includes('not active')) {
2296
+ throw new HttpError(409, msg);
2297
+ }
2298
+ throw e;
2299
+ }
2300
+ return;
2301
+ }
2302
+ const noteByIdMatch = path.match(/^\/v1\/customer-notes\/(\d+)$/);
2303
+ if (method === 'GET' && noteByIdMatch) {
2304
+ const id = parseInt(noteByIdMatch[1], 10);
2305
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
2306
+ const customerNote = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
2307
+ if (!customerNote) {
2308
+ throw new HttpError(404, `customer note ${id} not found`);
2309
+ }
2310
+ sendJson(res, 200, { note: customerNote });
2311
+ return;
2312
+ }
1301
2313
  // ── POST /v1/connectors/slack/events ──
1302
2314
  //
1303
2315
  // Slack Events API webhook. Auth is signature-based (HMAC over the raw