agentwrangler 0.1.1-next.bca85ea → 0.1.1-next.bdce596

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 (98) hide show
  1. package/README.md +5 -3
  2. package/dist/daemon/http.js +175 -34
  3. package/dist/daemon/index.js +44 -81
  4. package/dist/daemon/lifecycle.js +122 -0
  5. package/dist/daemon/router.js +339 -9
  6. package/dist/db/migrations/019_work_records.sql +291 -0
  7. package/dist/db/migrations/020_local_command_retention.sql +50 -0
  8. package/dist/db/migrations/021_recommendation_feedback.sql +25 -0
  9. package/dist/detector/d7-loop-analysis.js +142 -0
  10. package/dist/detector/detectors/d7_loop_retry_waste.js +14 -127
  11. package/dist/effects/engine.js +27 -10
  12. package/dist/effects/index.js +1 -0
  13. package/dist/effects/registry.js +87 -6
  14. package/dist/effects/scope-identity.js +19 -0
  15. package/dist/effects/sql-observer.js +322 -8
  16. package/dist/ingest/boot-progress.js +76 -0
  17. package/dist/ingest/ingestor.js +352 -82
  18. package/dist/ingest/parser.js +19 -3
  19. package/dist/ingest/tail.js +130 -20
  20. package/dist/ingest/types.js +1 -1
  21. package/dist/query/api/decision-ranker-types.js +6 -0
  22. package/dist/query/api/decision-ranker.js +393 -0
  23. package/dist/query/api/effect-service.js +2 -0
  24. package/dist/query/api/esf-observations.js +117 -62
  25. package/dist/query/api/evidence-packet-types.js +3 -0
  26. package/dist/query/api/evidence-packet.js +316 -0
  27. package/dist/query/api/feedback-store.js +286 -0
  28. package/dist/query/api/index.js +1 -1
  29. package/dist/query/api/opportunity-composer-types.js +3 -0
  30. package/dist/query/api/opportunity-composer.js +303 -0
  31. package/dist/query/api/overview.js +42 -1
  32. package/dist/query/api/reported-work.js +46 -0
  33. package/dist/query/api/session-drivers.js +1 -1
  34. package/dist/query/api/spend-flavor.js +22 -15
  35. package/dist/query/api/trends.js +86 -6
  36. package/dist/query/api/work-records.js +85 -0
  37. package/dist/query/settings-store.js +11 -0
  38. package/dist/query/spend.js +27 -13
  39. package/dist/query/trends.js +27 -0
  40. package/dist/ui/assets/{BarChart-CIjCNfbI.js → BarChart-C9vtAcml.js} +1 -1
  41. package/dist/ui/assets/{BriefsPage-DIU8iFjV.js → BriefsPage-DLp1pInz.js} +2 -2
  42. package/dist/ui/assets/{CacheWriteSpikesChart-1Nfnf7OX.js → CacheWriteSpikesChart-BlflM-xW.js} +1 -1
  43. package/dist/ui/assets/{CartesianChart-DEnDOGKj.js → CartesianChart-0_RUWlzc.js} +1 -1
  44. package/dist/ui/assets/{Chip-s-32Dh3o.js → Chip-BQ4UlaAL.js} +1 -1
  45. package/dist/ui/assets/{ComposedChart-DUw8Y79v.js → ComposedChart-CIG9CS0j.js} +1 -1
  46. package/dist/ui/assets/{EmptyState-B8vpug3_.js → EmptyState-CjBh_Y06.js} +1 -1
  47. package/dist/ui/assets/FlavorDecomposition-Cb1Et9e_.js +1 -0
  48. package/dist/ui/assets/{FrictionCell-X75fKRBK.js → FrictionCell-BAcKJbGq.js} +1 -1
  49. package/dist/ui/assets/{GlossaryPage-D1tYumqL.js → GlossaryPage-C0nFEAdc.js} +1 -1
  50. package/dist/ui/assets/HotSessionsPage-C6obf1m9.js +1 -0
  51. package/dist/ui/assets/InfoTip-Dc4F2ylc.js +1 -0
  52. package/dist/ui/assets/{Legend-CB7sqqJH.js → Legend-Ck2QXclg.js} +1 -1
  53. package/dist/ui/assets/{Line-B9XY30uT.js → Line-CSuEmMnn.js} +1 -1
  54. package/dist/ui/assets/Modal-C5VeGRjW.js +1 -0
  55. package/dist/ui/assets/ObservationEvidence-Bhnk-xkX.css +1 -0
  56. package/dist/ui/assets/ObservationEvidence-CP8cvr_5.js +1 -0
  57. package/dist/ui/assets/OverviewPage-CCyQ2Oxn.js +2 -0
  58. package/dist/ui/assets/RecommendationsPage-Bs1B1AJ0.js +2 -0
  59. package/dist/ui/assets/{Scatter-BSWm_yIZ.js → Scatter-BiTrTZ1e.js} +1 -1
  60. package/dist/ui/assets/SectionHeader-BcQQCuZA.js +1 -0
  61. package/dist/ui/assets/SessionDetailPage-CcPL36Cv.js +1 -0
  62. package/dist/ui/assets/SettingsPage-CzAe6Xf1.js +25 -0
  63. package/dist/ui/assets/Skeleton-D1ey4h0q.js +1 -0
  64. package/dist/ui/assets/SpendPercentileChip-DiTZ1xoI.js +1 -0
  65. package/dist/ui/assets/{TrendChart-BSLwlBiq.js → TrendChart-B51LSzds.js} +1 -1
  66. package/dist/ui/assets/WorkspaceDetailPage-B4LWopuo.js +1 -0
  67. package/dist/ui/assets/WorkspacesPage-m4Km-zOE.js +1 -0
  68. package/dist/ui/assets/chart-theme-BAFdtBab.js +1 -0
  69. package/dist/ui/assets/{graphicalItemSelectors-0-Ue_y1f.js → graphicalItemSelectors-yTOJ8llA.js} +1 -1
  70. package/dist/ui/assets/index-DTmPINeX.js +41 -0
  71. package/dist/ui/assets/index-qkEcvt6k.css +1 -0
  72. package/dist/ui/assets/{prompt-templates-CVzMex9L.js → prompt-templates-CVAAu0IZ.js} +1 -1
  73. package/dist/ui/assets/short-id-C12NbqLn.js +1 -0
  74. package/dist/ui/assets/{useExperimentalActions-aI00AFZv.js → useExperimentalActions-BVXB2b0W.js} +1 -1
  75. package/dist/ui/assets/workspace-names-DW0HTwW6.js +1 -0
  76. package/dist/ui/index.html +2 -2
  77. package/dist/work-records/allocation.js +242 -0
  78. package/dist/work-records/ids.js +80 -0
  79. package/dist/work-records/index.js +4 -0
  80. package/dist/work-records/store.js +376 -0
  81. package/dist/work-records/types.js +29 -0
  82. package/dist/work-records/validation.js +71 -0
  83. package/package.json +4 -3
  84. package/dist/ui/assets/FlavorDecomposition-CkHX3M-U.js +0 -1
  85. package/dist/ui/assets/HotSessionsPage-8HlRe0VB.js +0 -1
  86. package/dist/ui/assets/InfoTip-CL8RC4vz.js +0 -1
  87. package/dist/ui/assets/OverviewPage-DL4joBBE.js +0 -2
  88. package/dist/ui/assets/RecommendationsPage-NkfH17eB.js +0 -2
  89. package/dist/ui/assets/SessionDetailPage-Ceqt7poO.js +0 -1
  90. package/dist/ui/assets/SettingsPage-ZK4ii5h_.js +0 -25
  91. package/dist/ui/assets/Skeleton-Dp_m79B8.js +0 -1
  92. package/dist/ui/assets/SpendPercentileChip-CzY6h1gP.js +0 -1
  93. package/dist/ui/assets/WorkspaceDetailPage-DFOdEB39.js +0 -1
  94. package/dist/ui/assets/WorkspacesPage-Doct6meK.js +0 -1
  95. package/dist/ui/assets/chart-theme-DYgw_Tac.js +0 -1
  96. package/dist/ui/assets/index-D_IJsvkJ.css +0 -1
  97. package/dist/ui/assets/index-DhFiA8Ch.js +0 -41
  98. package/dist/ui/assets/workspace-label-CysPJNc0.js +0 -1
@@ -1,12 +1,6 @@
1
- /**
2
- * src/daemon/router.ts — minimal API route dispatcher.
3
- *
4
- * Routes /api/* requests to the LocalQueryAPI stubs.
5
- * WP2 and WP4 replace the stub calls with real implementations.
6
- *
7
- * All responses are JSON with no permissive CORS headers.
8
- */
1
+ import { getReportedWork } from "../query/api/reported-work.js";
9
2
  import { getPractices } from "../detector/practice-registry.js";
3
+ import { getBootProgress } from "../ingest/boot-progress.js";
10
4
  import { getOAuthStatus } from "../oauth/credentials.js";
11
5
  import { getGithubTokenStatus } from "../outcomes/github/credential.js";
12
6
  import { manualLink, manualUnlink } from "../outcomes/linker.js";
@@ -17,15 +11,18 @@ import { getDeliveryMetrics } from "../query/api/delivery.js";
17
11
  import { EffectRequestError, listEffectEvidence, mutateEffectCycle, trackCompletedChange, } from "../query/api/effect-service.js";
18
12
  import { getClosureProxy } from "../query/api/effectiveness.js";
19
13
  import { getEfficiencyHeadroom } from "../query/api/efficiency-headroom.js";
14
+ import { getEsfObservations, getSessionEsfObservations } from "../query/api/esf-observations.js";
15
+ import { FeedbackError, deleteFeedbackRoute, getGoalRoute, listFeedbackRoute, resetGoalRoute, setFeedbackRoute, setGoalRoute, undoFeedbackRoute, } from "../query/api/feedback-store.js";
20
16
  import { getHeadroomTrend } from "../query/api/headroom-trend.js";
21
17
  import { getHotSessions } from "../query/api/hot-sessions.js";
22
- import { adoptRecommendation, calibrateBytesPerToken, calibrateLimit, confirmApplyJob, dismissRecommendation, getApplyJob, getContextBudget, getContextComposition, getGlobalOverview, getHookConfig, getIdleSessions, getLinkageRate, getLoopGuard, getSession, getSessionDrivers, getSettings, getSuccessRate, getTurnTimeline, getWorkspaceOutcomeDetail, installHookRoute, listLedger, listLiveSessions, listRecommendations, listSessions, listWorkspaceOutcomes, listWorkspaces, openTerminalForRec, resetDatabase, rollbackApplyJob, startApplyJob, uninstallHookRoute, updateHookConfig, updateSettings, } from "../query/api/index.js";
18
+ import { adoptRecommendation, calibrateBytesPerToken, calibrateLimit, confirmApplyJob, dismissRecommendation, getApplyJob, getContextBudget, getContextComposition, getGlobalOverview, getHookConfig, getIdleSessions, getLinkageRate, getLoopGuard, getSession, getSessionDrivers, getSettings, getSuccessRate, getTurnTimeline, getWorkspaceOutcomeDetail, installHookRoute, listLedger, listLiveSessions, listRecommendations, listSessions, listWorkspaceNames, listWorkspaceOutcomes, listWorkspaces, openTerminalForRec, resetDatabase, rollbackApplyJob, startApplyJob, uninstallHookRoute, updateHookConfig, updateSettings, } from "../query/api/index.js";
23
19
  import { getOffloadShare } from "../query/api/offload-share.js";
24
20
  import { getReport, listReports } from "../query/api/reports.js";
25
21
  import { getSelfChurn } from "../query/api/self-churn.js";
26
22
  import { getSessionSpendPercentile, getWeeklySelfPercentile, } from "../query/api/self-percentiles.js";
27
23
  import { getFlavorDecomposition } from "../query/api/spend-flavor.js";
28
24
  import { getCacheWriteTrend, getTrends } from "../query/api/trends.js";
25
+ import { WorkRecordError, attachWorkRecordContextRoute, attachWorkRecordSessionRoute, closeoutWorkRecordRoute, createWorkRecordRoute, deleteWorkRecordRoute, detachWorkRecordContextRoute, detachWorkRecordSessionRoute, editWorkRecordRoute, getWorkAllocation, getWorkAllocationClaimKinds, getWorkRecord, issueWorkRecordId, listWorkRecords, recomputeWorkAllocationRoute, setWorkRecordArchivedRoute, workRecordResponse, } from "../query/api/work-records.js";
29
26
  import { getSettingsData } from "../query/settings-store.js";
30
27
  import { getScanStatus, isReady } from "./readiness.js";
31
28
  function sendJson(res, status, body) {
@@ -36,6 +33,39 @@ function sendJson(res, status, body) {
36
33
  });
37
34
  res.end(payload);
38
35
  }
36
+ function sendFeedbackError(res, error) {
37
+ if (error instanceof FeedbackError) {
38
+ sendJson(res, error.status, { error: error.message, code: error.code });
39
+ return;
40
+ }
41
+ // Oversized body (readBody) rejects with EffectRequestError(400, …).
42
+ if (error instanceof EffectRequestError) {
43
+ sendJson(res, error.status, { error: error.message, code: "INVALID_REQUEST" });
44
+ return;
45
+ }
46
+ sendJson(res, 500, { error: "Internal error" });
47
+ }
48
+ /** Read + JSON-parse a write body, then run `run` and send its enveloped result. */
49
+ function withJsonBody(req, res, run) {
50
+ readBody(req, 4096)
51
+ .then((raw) => {
52
+ let body;
53
+ try {
54
+ body = JSON.parse(raw);
55
+ }
56
+ catch {
57
+ sendJson(res, 400, { error: "Invalid JSON body", code: "INVALID_REQUEST" });
58
+ return;
59
+ }
60
+ try {
61
+ sendJson(res, 200, run(body));
62
+ }
63
+ catch (error) {
64
+ sendFeedbackError(res, error);
65
+ }
66
+ })
67
+ .catch((error) => sendFeedbackError(res, error));
68
+ }
39
69
  function readBody(req, maxBytes) {
40
70
  return new Promise((resolve, reject) => {
41
71
  const chunks = [];
@@ -134,10 +164,96 @@ function jobRoute(pathname) {
134
164
  const action = m[2] === "confirm" || m[2] === "rollback" ? m[2] : "get";
135
165
  return { jobId: decodeURIComponent(m[1]), action };
136
166
  }
167
+ function workRecordId(pathname) {
168
+ const match = pathname.match(/^\/api\/work-records\/([^/]+)$/);
169
+ return match?.[1] === undefined ? null : decodeURIComponent(match[1]);
170
+ }
171
+ function listSavedCostReports(db, workspaceId) {
172
+ return db
173
+ .prepare(`SELECT r.allocation_revision_id, r.created_at, r.cohort_from, r.cohort_to,
174
+ SUM(CASE WHEN a.disposition = 'OWNED' THEN 1 ELSE 0 END) AS allocated_session_count,
175
+ COUNT(a.session_id) AS eligible_session_count
176
+ FROM work_allocation_revisions r
177
+ LEFT JOIN work_session_allocations a ON a.allocation_revision_id = r.allocation_revision_id
178
+ WHERE r.workspace_id = ?
179
+ GROUP BY r.allocation_revision_id
180
+ ORDER BY r.created_at DESC, r.allocation_revision_id DESC`)
181
+ .all(workspaceId);
182
+ }
183
+ function workRecordAction(pathname) {
184
+ const match = pathname.match(/^\/api\/work-records\/([^/]+)\/(edit|closeout|archive|reopen|sessions|contexts|delete)(?:\/([^/]+)\/detach)?$/);
185
+ if (match?.[1] === undefined || match[2] === undefined)
186
+ return null;
187
+ const result = { recordId: decodeURIComponent(match[1]), action: match[2] };
188
+ return match[3] === undefined ? result : { ...result, relatedId: decodeURIComponent(match[3]) };
189
+ }
190
+ function workRecordBody(raw, recordId, related) {
191
+ let body;
192
+ try {
193
+ body = JSON.parse(raw);
194
+ }
195
+ catch {
196
+ throw new WorkRecordError(400, "INVALID_REQUEST", "Invalid JSON body");
197
+ }
198
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
199
+ throw new WorkRecordError(400, "INVALID_REQUEST", "request body must be an object");
200
+ }
201
+ const result = { ...body };
202
+ if (recordId !== undefined) {
203
+ if (result.work_record_id !== undefined && result.work_record_id !== recordId) {
204
+ throw new WorkRecordError(400, "INVALID_REQUEST", "work_record_id must match the route");
205
+ }
206
+ result.work_record_id = recordId;
207
+ }
208
+ if (related !== undefined) {
209
+ if (result[related.key] !== undefined && result[related.key] !== related.value) {
210
+ throw new WorkRecordError(400, "INVALID_REQUEST", `${related.key} must match the route`);
211
+ }
212
+ result[related.key] = related.value;
213
+ }
214
+ return result;
215
+ }
216
+ function sendWorkRecordError(res, error) {
217
+ if (error instanceof EffectRequestError) {
218
+ sendJson(res, error.status, { error: error.message, code: "INVALID_REQUEST" });
219
+ return;
220
+ }
221
+ if (error instanceof WorkRecordError) {
222
+ sendJson(res, error.status, { error: error.message, code: error.code });
223
+ return;
224
+ }
225
+ sendJson(res, 500, { error: "Internal error" });
226
+ }
137
227
  export function handleApiRequest(_db, req, res, method, url) {
138
228
  // Strip query string for routing.
139
229
  const pathname = url.split("?")[0] ?? url;
140
230
  try {
231
+ if (method === "GET" && pathname === "/api/work-records/summary") {
232
+ const params = new URLSearchParams(url.split("?")[1] ?? "");
233
+ sendJson(res, 200, getReportedWork(_db, params.get("workspace_id") || null));
234
+ return;
235
+ }
236
+ // GET /api/esf/observations — complete selected-turn accounting cohort.
237
+ if (method === "GET" && pathname === "/api/esf/observations") {
238
+ const params = new URLSearchParams(url.split("?")[1] ?? "");
239
+ const window = resolveWindow(parseWindowFilter(url));
240
+ if (Date.parse(window.from) >= Date.parse(window.to)) {
241
+ sendJson(res, 400, { error: "from must precede to" });
242
+ return;
243
+ }
244
+ sendJson(res, 200, getEsfObservations(_db, {
245
+ workspaceId: params.get("workspace_id") || null,
246
+ ...window,
247
+ }));
248
+ return;
249
+ }
250
+ // Full-session aggregate: never derive this from a paginated timeline.
251
+ const sessionObservationId = pathname.match(/^\/api\/esf\/session-observations\/([^/]+)$/)?.[1];
252
+ if (method === "GET" && sessionObservationId !== undefined) {
253
+ const evidence = getSessionEsfObservations(_db, decodeURIComponent(sessionObservationId));
254
+ sendJson(res, evidence.data === null ? 404 : 200, evidence);
255
+ return;
256
+ }
141
257
  // GET /api/esf/effects — independently keyset-paged versioned and legacy evidence.
142
258
  if (method === "GET" && pathname === "/api/esf/effects") {
143
259
  const params = new URLSearchParams(url.split("?")[1] ?? "");
@@ -179,6 +295,172 @@ export function handleApiRequest(_db, req, res, method, url) {
179
295
  }));
180
296
  return;
181
297
  }
298
+ // ESF3 work records are local-only, structured records. The HTTP token gate
299
+ // in http.ts covers every mutation and ID issuance route below.
300
+ if (method === "GET" && pathname === "/api/work-records") {
301
+ const params = new URLSearchParams(url.split("?")[1] ?? "");
302
+ const workspaceId = params.get("workspace_id");
303
+ const includeArchived = params.get("include_archived");
304
+ if (workspaceId === null ||
305
+ workspaceId.length === 0 ||
306
+ (includeArchived !== null && includeArchived !== "true" && includeArchived !== "false")) {
307
+ sendJson(res, 400, { error: "workspace_id and include_archived are invalid" });
308
+ return;
309
+ }
310
+ const records = listWorkRecords(_db, workspaceId, includeArchived === "true");
311
+ sendJson(res, 200, workRecordResponse(records, { n: records.length, workspaceId }));
312
+ return;
313
+ }
314
+ if (method === "GET" && pathname === "/api/work-records/allocations") {
315
+ const workspaceId = new URLSearchParams(url.split("?")[1] ?? "").get("workspace_id");
316
+ if (workspaceId === null || workspaceId.length === 0) {
317
+ sendJson(res, 400, { error: "workspace_id is required" });
318
+ return;
319
+ }
320
+ const reports = listSavedCostReports(_db, workspaceId);
321
+ sendJson(res, 200, workRecordResponse(reports, { n: reports.length, workspaceId }));
322
+ return;
323
+ }
324
+ if (method === "GET" && pathname.startsWith("/api/work-records/allocations/")) {
325
+ const allocationId = pathname.slice("/api/work-records/allocations/".length);
326
+ if (allocationId.length === 0 || allocationId.includes("/")) {
327
+ sendJson(res, 404, { error: "Not found" });
328
+ return;
329
+ }
330
+ try {
331
+ const allocation = getWorkAllocation(_db, decodeURIComponent(allocationId));
332
+ sendJson(res, 200, workRecordResponse(allocation, {
333
+ n: allocation.eligible_session_count,
334
+ workspaceId: allocation.workspace_id,
335
+ claimKind: "EXPERIMENTAL",
336
+ meta: {
337
+ window: { from: allocation.cohort_from, to: allocation.cohort_to },
338
+ qualification: {
339
+ provisional_excluded: true,
340
+ unpriced_turns: allocation.unpriced_turn_count,
341
+ claim_kinds_count: getWorkAllocationClaimKinds(_db, allocation.allocation_revision_id),
342
+ note: "Frozen ESF3 allocation; unpriced turns are not zero cost.",
343
+ },
344
+ },
345
+ }));
346
+ }
347
+ catch (error) {
348
+ sendWorkRecordError(res, error);
349
+ }
350
+ return;
351
+ }
352
+ const recordId = workRecordId(pathname);
353
+ if (method === "GET" && recordId !== null) {
354
+ try {
355
+ const record = getWorkRecord(_db, recordId);
356
+ sendJson(res, 200, workRecordResponse(record, { workspaceId: record.workspace_id }));
357
+ }
358
+ catch (error) {
359
+ sendWorkRecordError(res, error);
360
+ }
361
+ return;
362
+ }
363
+ if (method === "POST" && pathname === "/api/work-records/ids") {
364
+ readBody(req, 1024)
365
+ .then((raw) => {
366
+ try {
367
+ sendJson(res, 200, workRecordResponse(issueWorkRecordId(_db, workRecordBody(raw))));
368
+ }
369
+ catch (error) {
370
+ sendWorkRecordError(res, error);
371
+ }
372
+ })
373
+ .catch((error) => sendWorkRecordError(res, error));
374
+ return;
375
+ }
376
+ if (method === "POST" && pathname === "/api/work-records") {
377
+ readBody(req, 4096)
378
+ .then((raw) => {
379
+ try {
380
+ const result = createWorkRecordRoute(_db, workRecordBody(raw));
381
+ sendJson(res, 200, workRecordResponse(result, { workspaceId: result.record.workspace_id }));
382
+ }
383
+ catch (error) {
384
+ sendWorkRecordError(res, error);
385
+ }
386
+ })
387
+ .catch((error) => sendWorkRecordError(res, error));
388
+ return;
389
+ }
390
+ if (method === "POST" && pathname === "/api/work-records/allocations/recompute") {
391
+ readBody(req, 4096)
392
+ .then((raw) => {
393
+ try {
394
+ const result = recomputeWorkAllocationRoute(_db, workRecordBody(raw));
395
+ sendJson(res, 200, workRecordResponse(result, {
396
+ n: result.allocation.eligible_session_count,
397
+ workspaceId: result.allocation.workspace_id,
398
+ claimKind: "EXPERIMENTAL",
399
+ meta: {
400
+ window: {
401
+ from: result.allocation.cohort_from,
402
+ to: result.allocation.cohort_to,
403
+ },
404
+ qualification: {
405
+ provisional_excluded: true,
406
+ unpriced_turns: result.allocation.unpriced_turn_count,
407
+ claim_kinds_count: getWorkAllocationClaimKinds(_db, result.allocation.allocation_revision_id),
408
+ note: "Frozen ESF3 allocation; unpriced turns are not zero cost.",
409
+ },
410
+ },
411
+ }));
412
+ }
413
+ catch (error) {
414
+ sendWorkRecordError(res, error);
415
+ }
416
+ })
417
+ .catch((error) => sendWorkRecordError(res, error));
418
+ return;
419
+ }
420
+ const recordAction = workRecordAction(pathname);
421
+ if (recordAction !== null &&
422
+ ((method === "POST" && recordAction.action !== "delete") ||
423
+ (method === "DELETE" && recordAction.action === "delete"))) {
424
+ readBody(req, 4096)
425
+ .then((raw) => {
426
+ try {
427
+ const related = recordAction.relatedId === undefined
428
+ ? undefined
429
+ : {
430
+ key: recordAction.action === "contexts" ? "context_ref_id" : "session_id",
431
+ value: recordAction.relatedId,
432
+ };
433
+ const body = workRecordBody(raw, recordAction.recordId, related);
434
+ const result = recordAction.action === "edit"
435
+ ? editWorkRecordRoute(_db, body)
436
+ : recordAction.action === "closeout"
437
+ ? closeoutWorkRecordRoute(_db, body)
438
+ : recordAction.action === "archive"
439
+ ? setWorkRecordArchivedRoute(_db, body, true)
440
+ : recordAction.action === "reopen"
441
+ ? setWorkRecordArchivedRoute(_db, body, false)
442
+ : recordAction.action === "sessions" && recordAction.relatedId === undefined
443
+ ? attachWorkRecordSessionRoute(_db, body)
444
+ : recordAction.action === "sessions"
445
+ ? detachWorkRecordSessionRoute(_db, body)
446
+ : recordAction.action === "contexts" &&
447
+ recordAction.relatedId === undefined
448
+ ? attachWorkRecordContextRoute(_db, body)
449
+ : recordAction.action === "contexts"
450
+ ? detachWorkRecordContextRoute(_db, body)
451
+ : deleteWorkRecordRoute(_db, body);
452
+ const workspaceId = "record" in result ? result.record.workspace_id : undefined;
453
+ sendJson(res, 200, workspaceId === undefined
454
+ ? workRecordResponse(result)
455
+ : workRecordResponse(result, { workspaceId }));
456
+ }
457
+ catch (error) {
458
+ sendWorkRecordError(res, error);
459
+ }
460
+ })
461
+ .catch((error) => sendWorkRecordError(res, error));
462
+ return;
463
+ }
182
464
  // GET /api/overview
183
465
  if (method === "GET" && pathname === "/api/overview") {
184
466
  sendJson(res, 200, getGlobalOverview(parseWindowFilter(url)));
@@ -275,6 +557,11 @@ export function handleApiRequest(_db, req, res, method, url) {
275
557
  sendJson(res, 200, listWorkspaces(parseWindowFilter(url)));
276
558
  return;
277
559
  }
560
+ // GET /api/workspace-names (unwindowed naming metadata for all workspaces — UIR-8)
561
+ if (method === "GET" && pathname === "/api/workspace-names") {
562
+ sendJson(res, 200, listWorkspaceNames());
563
+ return;
564
+ }
278
565
  // GET /api/delivery (delivery proxy metrics across all workspaces)
279
566
  if (method === "GET" && pathname === "/api/delivery") {
280
567
  const { from, to } = resolveWindow(parseWindowFilter(url));
@@ -574,6 +861,46 @@ export function handleApiRequest(_db, req, res, method, url) {
574
861
  .catch(() => sendJson(res, 500, { error: "Internal error reading request body" }));
575
862
  return;
576
863
  }
864
+ // ── RIQ3 relevance feedback + goal preference (migration 021) ──────────
865
+ // Local, enum-only, SEC-101. Writes are CSRF + session-token gated in http.ts.
866
+ if (method === "GET" && pathname === "/api/recommendations/feedback") {
867
+ const scopeKey = new URLSearchParams(url.split("?")[1] ?? "").get("scope_key");
868
+ if (!scopeKey) {
869
+ sendJson(res, 400, { error: "scope_key is required", code: "INVALID_REQUEST" });
870
+ return;
871
+ }
872
+ sendJson(res, 200, listFeedbackRoute(_db, scopeKey));
873
+ return;
874
+ }
875
+ if (method === "POST" && pathname === "/api/recommendations/feedback") {
876
+ withJsonBody(req, res, (body) => setFeedbackRoute(_db, body));
877
+ return;
878
+ }
879
+ if (method === "POST" && pathname === "/api/recommendations/feedback/undo") {
880
+ withJsonBody(req, res, (body) => undoFeedbackRoute(_db, body));
881
+ return;
882
+ }
883
+ if (method === "DELETE" && pathname === "/api/recommendations/feedback") {
884
+ withJsonBody(req, res, (body) => deleteFeedbackRoute(_db, body));
885
+ return;
886
+ }
887
+ if (method === "GET" && pathname === "/api/recommendations/goal") {
888
+ const scopeKey = new URLSearchParams(url.split("?")[1] ?? "").get("scope_key");
889
+ if (!scopeKey) {
890
+ sendJson(res, 400, { error: "scope_key is required", code: "INVALID_REQUEST" });
891
+ return;
892
+ }
893
+ sendJson(res, 200, getGoalRoute(_db, scopeKey));
894
+ return;
895
+ }
896
+ if (method === "POST" && pathname === "/api/recommendations/goal") {
897
+ withJsonBody(req, res, (body) => setGoalRoute(_db, body));
898
+ return;
899
+ }
900
+ if (method === "POST" && pathname === "/api/recommendations/goal/reset") {
901
+ withJsonBody(req, res, (body) => resetGoalRoute(_db, body));
902
+ return;
903
+ }
577
904
  // GET /api/settings
578
905
  if (method === "GET" && pathname === "/api/settings") {
579
906
  sendJson(res, 200, getSettings());
@@ -796,6 +1123,9 @@ export function handleApiRequest(_db, req, res, method, url) {
796
1123
  files_parsed: parser_health.files_parsed,
797
1124
  lines_quarantined: parser_health.lines_quarantined,
798
1125
  ...getScanStatus(),
1126
+ // BOOT-1: boot-progress fields exist ONLY pre-ready; the steady-state
1127
+ // /api/status shape is unchanged (UI fixtures must not require them).
1128
+ ...(isReady() ? {} : getBootProgress()),
799
1129
  });
800
1130
  return;
801
1131
  }