thumbgate 1.29.1 → 1.30.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 (77) hide show
  1. package/.claude/commands/dashboard.md +11 -1
  2. package/.claude/commands/thumbgate-dashboard.md +23 -8
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.well-known/mcp/server-card.json +1 -1
  5. package/README.md +61 -1
  6. package/adapters/claude/.mcp.json +2 -2
  7. package/adapters/forge/forge.yaml +3 -3
  8. package/adapters/mcp/server-stdio.js +164 -7
  9. package/adapters/opencode/opencode.json +1 -1
  10. package/bin/cli.js +7 -5
  11. package/commands/dashboard.md +11 -1
  12. package/commands/thumbgate-dashboard.md +23 -8
  13. package/config/agent-outcome-monitor-thresholds.json +63 -0
  14. package/config/evals/agent-outcomes-baseline.json +17 -0
  15. package/config/evals/agent-outcomes-golden.json +412 -0
  16. package/config/evals/prompt-eval-baseline.json +23 -0
  17. package/config/mcp-allowlists.json +26 -2
  18. package/config/post-deploy-marketing-pages.json +26 -1
  19. package/config/schemas/task-outcome-receipt.schema.json +296 -0
  20. package/openapi/openapi.yaml +235 -0
  21. package/package.json +55 -11
  22. package/public/architecture.html +130 -0
  23. package/public/assets/diagrams/agent-integration.png +0 -0
  24. package/public/assets/diagrams/before-after.svg +21 -0
  25. package/public/assets/diagrams/decision.svg +36 -0
  26. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  27. package/public/assets/diagrams/loop.svg +34 -0
  28. package/public/assets/diagrams/plugin-topology.png +0 -0
  29. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  30. package/public/assets/diagrams/stack.svg +18 -0
  31. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  32. package/public/case-studies.html +151 -0
  33. package/public/eval-scorecard.html +195 -0
  34. package/public/eval-scorecard.json +18 -0
  35. package/public/evaluations.html +168 -0
  36. package/public/index.html +6 -3
  37. package/public/numbers.html +2 -2
  38. package/public/whitepaper.html +189 -0
  39. package/scripts/activation-quickstart.js +1 -0
  40. package/scripts/agent-outcome-eval.js +130 -0
  41. package/scripts/agent-outcome-monitor.js +331 -0
  42. package/scripts/agent-reasoning-traces.js +8 -9
  43. package/scripts/async-job-runner.js +107 -13
  44. package/scripts/billing.js +3 -1
  45. package/scripts/claude-feedback-sync.js +3 -2
  46. package/scripts/cli-feedback.js +13 -7
  47. package/scripts/cross-encoder-reranker.js +3 -0
  48. package/scripts/durability/step.js +121 -12
  49. package/scripts/feedback-aggregate.js +5 -2
  50. package/scripts/feedback-loop.js +244 -182
  51. package/scripts/gates-engine.js +512 -22
  52. package/scripts/generate-case-study-outreach.js +253 -0
  53. package/scripts/generate-eval-scorecard.js +276 -0
  54. package/scripts/growth-campaigns.js +183 -0
  55. package/scripts/human-escalation.js +265 -0
  56. package/scripts/hybrid-feedback-context.js +93 -50
  57. package/scripts/jsonl-watcher.js +1 -0
  58. package/scripts/judge-reward-function.js +30 -18
  59. package/scripts/lesson-inference.js +23 -4
  60. package/scripts/lesson-retrieval.js +71 -4
  61. package/scripts/lesson-search.js +26 -3
  62. package/scripts/mcp-config.js +26 -5
  63. package/scripts/mcp-oauth.js +37 -2
  64. package/scripts/model-eval.js +308 -0
  65. package/scripts/parallel-workflow-orchestrator.js +86 -22
  66. package/scripts/prompt-eval.js +81 -4
  67. package/scripts/published-cli.js +11 -1
  68. package/scripts/refresh-proof-pack.js +261 -0
  69. package/scripts/risk-scorer.js +144 -15
  70. package/scripts/schedule-manager.js +249 -0
  71. package/scripts/statusline-local-stats.js +1 -1
  72. package/scripts/task-outcomes.js +425 -0
  73. package/scripts/thumbgate-bench.js +13 -0
  74. package/scripts/tool-contract-validator.js +287 -59
  75. package/scripts/tool-kpi-tracker.js +124 -0
  76. package/scripts/tool-registry.js +192 -1
  77. package/src/api/server.js +355 -89
package/src/api/server.js CHANGED
@@ -166,6 +166,20 @@ const {
166
166
  const {
167
167
  evaluateWorkflowSentinel,
168
168
  } = require('../../scripts/workflow-sentinel');
169
+ const {
170
+ calculateTaskOutcomeMetrics,
171
+ getTaskOutcome,
172
+ readTaskOutcomes,
173
+ recordTaskOutcome,
174
+ } = require('../../scripts/task-outcomes');
175
+ const { monitorProductionSignals } = require('../../scripts/agent-outcome-monitor');
176
+ const { computeToolKpis, recordToolCall } = require('../../scripts/tool-kpi-tracker');
177
+ const {
178
+ calculateEscalationMetrics,
179
+ decideEscalation,
180
+ listEscalations,
181
+ requestEscalation,
182
+ } = require('../../scripts/human-escalation');
169
183
  const {
170
184
  normalizeProviderAction,
171
185
  } = require('../../scripts/provider-action-normalizer');
@@ -224,6 +238,7 @@ const { sendProblem, PROBLEM_TYPES } = require('../../scripts/problem-detail');
224
238
  const { TOOLS: MCP_TOOLS } = require('../../scripts/tool-registry');
225
239
  const mcpOauth = require('../../scripts/mcp-oauth');
226
240
  const { packCheckoutReference } = require('../../scripts/checkout-attribution-reference');
241
+ const { normalizeCampaignId } = require('../../scripts/growth-campaigns');
227
242
  // OAuth 2.1 (PKCE) authorization-server state for the remote MCP connector
228
243
  // (Claude Connectors Directory requires OAuth for authenticated services).
229
244
  const oauthStore = mcpOauth.createStore();
@@ -253,6 +268,11 @@ const CODEX_PLUGIN_PAGE_PATH = path.resolve(__dirname, '../../public/codex-plugi
253
268
  const COMPARE_PAGE_PATH = path.resolve(__dirname, '../../public/compare.html');
254
269
  const LEARN_PAGE_PATH = path.resolve(__dirname, '../../public/learn.html');
255
270
  const NUMBERS_PAGE_PATH = path.resolve(__dirname, '../../public/numbers.html');
271
+ const ARCHITECTURE_PAGE_PATH = path.resolve(__dirname, '../../public/architecture.html');
272
+ const WHITEPAPER_PAGE_PATH = path.resolve(__dirname, '../../public/whitepaper.html');
273
+ const EVAL_SCORECARD_PAGE_PATH = path.resolve(__dirname, '../../public/eval-scorecard.html');
274
+ const EVALUATIONS_PAGE_PATH = path.resolve(__dirname, '../../public/evaluations.html');
275
+ const CASE_STUDIES_PAGE_PATH = path.resolve(__dirname, '../../public/case-studies.html');
256
276
  const FEDERAL_PAGE_PATH = path.resolve(__dirname, '../../public/federal.html');
257
277
  const PRICING_PAGE_PATH = path.resolve(__dirname, '../../public/pricing.html');
258
278
  const ABOUT_PAGE_PATH = path.resolve(__dirname, '../../public/about.html');
@@ -404,7 +424,7 @@ const BUILD_METADATA = resolveBuildMetadata();
404
424
  const TERMINAL_JOB_STATUSES = new Set(['completed', 'failed', 'cancelled']);
405
425
  const IDLE_JOB_STATUSES = new Set(['queued', 'paused', 'resume_requested']);
406
426
  const JOB_CONTROL_ACTIONS = new Set(['pause', 'cancel', 'resume']);
407
- const TRACKED_LINK_QUERY_KEYS = [
427
+ const TRACKED_LINK_QUERY_KEYS = new Set([
408
428
  'utm_source',
409
429
  'utm_medium',
410
430
  'utm_campaign',
@@ -432,7 +452,7 @@ const TRACKED_LINK_QUERY_KEYS = [
432
452
  'seat_count',
433
453
  'landing_path',
434
454
  'referrer_host',
435
- ];
455
+ ]);
436
456
  const TRACKED_LINK_TARGETS = Object.freeze({
437
457
  gpt: {
438
458
  href: 'https://chatgpt.com/g/g-69dcfd1cd5f881918ae31874631d6f08-thumbgate',
@@ -863,11 +883,13 @@ function updateLessonRecord(feedbackDir, lessonId, updater) {
863
883
  }
864
884
 
865
885
  function getPublicMcpTools() {
866
- return MCP_TOOLS.map((tool) => ({
886
+ const { getExposedTools } = require('../../adapters/mcp/server-stdio');
887
+ return getExposedTools().map((tool) => ({
867
888
  name: tool.name,
868
889
  ...(tool.title ? { title: tool.title } : {}),
869
890
  description: tool.description,
870
891
  inputSchema: tool.inputSchema,
892
+ ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
871
893
  // Serve the tool-registry annotations (readOnlyHint/destructiveHint). Required
872
894
  // by the Claude Connectors Directory (missing annotations = the #1 rejection
873
895
  // cause) and used by MCP clients for permission prompts. Was being dropped here.
@@ -876,13 +898,7 @@ function getPublicMcpTools() {
876
898
  }
877
899
 
878
900
  function getServerCardTools() {
879
- return MCP_TOOLS.map((tool) => ({
880
- name: tool.name,
881
- ...(tool.title ? { title: tool.title } : {}),
882
- description: tool.description,
883
- inputSchema: tool.inputSchema,
884
- ...(tool.annotations ? { annotations: tool.annotations } : {}),
885
- }));
901
+ return getPublicMcpTools();
886
902
  }
887
903
 
888
904
  function buildPublicUrl(hostedConfig, pathname) {
@@ -892,7 +908,7 @@ function buildPublicUrl(hostedConfig, pathname) {
892
908
  const VERIFICATION_EVIDENCE_URL = 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md';
893
909
 
894
910
  function getToolDiscoveryIndex(hostedConfig) {
895
- return MCP_TOOLS.map((tool) => ({
911
+ return getPublicMcpTools().map((tool) => ({
896
912
  name: tool.name,
897
913
  description: tool.description,
898
914
  annotations: tool.annotations || {},
@@ -902,7 +918,7 @@ function getToolDiscoveryIndex(hostedConfig) {
902
918
 
903
919
  function getContextFootprintReport(hostedConfig) {
904
920
  return buildContextFootprintReport({
905
- tools: MCP_TOOLS,
921
+ tools: getPublicMcpTools(),
906
922
  schemaUrlTemplate: buildPublicUrl(hostedConfig, '/.well-known/mcp/tools/{name}.json'),
907
923
  });
908
924
  }
@@ -1260,7 +1276,8 @@ function inferSearchQuery(referrer) {
1260
1276
 
1261
1277
  function getAttributionValue(params, key, fallbackValue) {
1262
1278
  const value = params.get(key);
1263
- return value && value.trim() ? value.trim() : fallbackValue;
1279
+ const resolved = value?.trim() || fallbackValue;
1280
+ return key === 'utm_campaign' ? normalizeCampaignId(resolved) : resolved;
1264
1281
  }
1265
1282
 
1266
1283
  function parseUrlSearchParams(urlValue) {
@@ -1632,17 +1649,28 @@ function projectWorkflowIntakeQueueLead(lead = {}, options = {}) {
1632
1649
  page: attribution.page || null,
1633
1650
  landingPath: attribution.landingPath || null,
1634
1651
  },
1635
- nextOperatorStep: options.closePacket?.status === 'approval_ready_not_authorized'
1636
- ? 'request_action_time_approval'
1637
- : options.discoveryPacket?.status === 'approval_ready_not_authorized'
1638
- ? 'request_action_time_approval_for_discovery'
1639
- : lead.status === 'new' ? 'review_and_qualify' : 'prepare_scope_or_hold',
1652
+ nextOperatorStep: resolveNextOperatorStep(lead, options),
1640
1653
  qualificationCard: options.qualificationCard || buildIntakeQualificationCard(lead, options),
1641
1654
  discoveryPacket: options.discoveryPacket || buildIntakeDiscoveryPacket(lead, options),
1642
1655
  closePacket: options.closePacket || buildIntakeClosePacket(lead, options),
1643
1656
  };
1644
1657
  }
1645
1658
 
1659
+ /**
1660
+ * Which action the operator should take next on an intake lead. Extracted from a three-level
1661
+ * nested ternary: the precedence (close packet, then discovery packet, then lead status) is the
1662
+ * actual logic and is far easier to check when it reads top to bottom.
1663
+ */
1664
+ function resolveNextOperatorStep(lead, options) {
1665
+ if (options.closePacket?.status === 'approval_ready_not_authorized') {
1666
+ return 'request_action_time_approval';
1667
+ }
1668
+ if (options.discoveryPacket?.status === 'approval_ready_not_authorized') {
1669
+ return 'request_action_time_approval_for_discovery';
1670
+ }
1671
+ return lead.status === 'new' ? 'review_and_qualify' : 'prepare_scope_or_hold';
1672
+ }
1673
+
1646
1674
  function buildWorkflowIntakeQueue(parsed, workflowSprintIntake, feedbackDir, options = {}) {
1647
1675
  const { limit, statuses } = resolveWorkflowIntakeQueueOptions(parsed, workflowSprintIntake);
1648
1676
  const leads = workflowSprintIntake.loadWorkflowSprintLeads(feedbackDir);
@@ -1721,7 +1749,7 @@ function sendInvalidAnalyticsWindowProblem(res, title, err) {
1721
1749
  }
1722
1750
 
1723
1751
  function sendWorkflowIntakeQueueProblem(res, err) {
1724
- const status = Number(err && err.statusCode) || 400;
1752
+ const status = Number(err?.statusCode) || 400;
1725
1753
  sendProblem(res, {
1726
1754
  type: status >= 500 ? PROBLEM_TYPES.INTERNAL : PROBLEM_TYPES.INVALID_REQUEST,
1727
1755
  title: status >= 500
@@ -2676,7 +2704,10 @@ function appendTrackedLinkQueryParams(destinationUrl, parsed, target) {
2676
2704
  for (const key of TRACKED_LINK_QUERY_KEYS) {
2677
2705
  const value = params.get(key);
2678
2706
  if (value && value.trim()) {
2679
- destinationUrl.searchParams.set(key, value.trim());
2707
+ const normalizedValue = key === 'utm_campaign'
2708
+ ? normalizeCampaignId(value)
2709
+ : value.trim();
2710
+ destinationUrl.searchParams.set(key, normalizedValue);
2680
2711
  }
2681
2712
  }
2682
2713
  if (target.allowCustomerEmail) {
@@ -2717,7 +2748,7 @@ function buildTrackedLinkAttribution(target, parsed, req, journeyState, destinat
2717
2748
  const source = pickFirstText(
2718
2749
  params.get('source'),
2719
2750
  params.get('utm_source'),
2720
- target.defaults && target.defaults.utm_source,
2751
+ target.defaults?.utm_source,
2721
2752
  inferSource(referrerHost)
2722
2753
  );
2723
2754
 
@@ -2731,19 +2762,21 @@ function buildTrackedLinkAttribution(target, parsed, req, journeyState, destinat
2731
2762
  traceId: pickFirstText(params.get('trace_id')),
2732
2763
  source,
2733
2764
  utmSource: pickFirstText(params.get('utm_source'), source),
2734
- utmMedium: pickFirstText(params.get('utm_medium'), target.defaults && target.defaults.utm_medium, 'link_router'),
2735
- utmCampaign: pickFirstText(params.get('utm_campaign'), target.defaults && target.defaults.utm_campaign, 'first_party_redirect'),
2765
+ utmMedium: pickFirstText(params.get('utm_medium'), target.defaults?.utm_medium, 'link_router'),
2766
+ utmCampaign: normalizeCampaignId(
2767
+ pickFirstText(params.get('utm_campaign'), target.defaults?.utm_campaign, 'first_party_redirect')
2768
+ ),
2736
2769
  utmContent: pickFirstText(params.get('utm_content')),
2737
2770
  utmTerm: pickFirstText(params.get('utm_term')),
2738
2771
  creator: pickFirstText(params.get('creator'), params.get('creator_handle')),
2739
2772
  community: pickFirstText(params.get('community'), params.get('subreddit')),
2740
2773
  postId: pickFirstText(params.get('post_id')),
2741
2774
  commentId: pickFirstText(params.get('comment_id')),
2742
- campaignVariant: pickFirstText(params.get('campaign_variant'), target.defaults && target.defaults.campaign_variant),
2775
+ campaignVariant: pickFirstText(params.get('campaign_variant'), target.defaults?.campaign_variant),
2743
2776
  offerCode: pickFirstText(params.get('offer_code')),
2744
2777
  ctaId: pickFirstText(params.get('cta_id'), target.ctaId),
2745
2778
  ctaPlacement: pickFirstText(params.get('cta_placement'), target.ctaPlacement),
2746
- planId: pickFirstText(params.get('plan_id'), target.defaults && target.defaults.plan_id),
2779
+ planId: pickFirstText(params.get('plan_id'), target.defaults?.plan_id),
2747
2780
  landingPath: pickFirstText(params.get('landing_path'), `/go/${target.slug}`),
2748
2781
  page: `/go/${target.slug}`,
2749
2782
  referrer,
@@ -3809,6 +3842,12 @@ function renderSitemapXml(runtimeConfig) {
3809
3842
  { path: '/codex-enterprise', changefreq: 'weekly', priority: '0.85' },
3810
3843
  { path: '/agents-cost-savings', changefreq: 'weekly', priority: '0.85' },
3811
3844
  { path: '/ai-malpractice-prevention', changefreq: 'weekly', priority: '0.9' },
3845
+ { path: '/whitepaper', changefreq: 'weekly', priority: '0.9' },
3846
+ { path: '/architecture', changefreq: 'weekly', priority: '0.85' },
3847
+ { path: '/eval-scorecard', changefreq: 'weekly', priority: '0.9' },
3848
+ { path: '/evaluations', changefreq: 'weekly', priority: '0.85' },
3849
+ { path: '/case-studies', changefreq: 'weekly', priority: '0.9' },
3850
+ { path: '/numbers', changefreq: 'weekly', priority: '0.8' },
3812
3851
  { path: '/learn/background-agent-control-layer', changefreq: 'weekly', priority: '0.85' },
3813
3852
  { path: '/learn/ac-dc-runtime-enforcement', changefreq: 'weekly', priority: '0.85' },
3814
3853
  { path: '/learn/feedback-loop-vs-decision-layer', changefreq: 'weekly', priority: '0.9' },
@@ -3875,6 +3914,8 @@ function buildHostedRuntimePresence(hostedConfig, { expectedApiKey, expectedOper
3875
3914
  THUMBGATE_FEEDBACK_DIR: Boolean(process.env.THUMBGATE_FEEDBACK_DIR),
3876
3915
  THUMBGATE_OPERATOR_KEY: Boolean(expectedOperatorKey),
3877
3916
  THUMBGATE_API_KEY: Boolean(expectedApiKey),
3917
+ THUMBGATE_HUMAN_REVIEWER_KEY: Boolean(process.env.THUMBGATE_HUMAN_REVIEWER_KEY),
3918
+ THUMBGATE_HUMAN_REVIEWER_ID: Boolean(process.env.THUMBGATE_HUMAN_REVIEWER_ID),
3878
3919
  THUMBGATE_PUBLIC_APP_ORIGIN: Boolean(hostedConfig.appOrigin),
3879
3920
  THUMBGATE_BILLING_API_BASE_URL: Boolean(hostedConfig.billingApiBaseUrl),
3880
3921
  THUMBGATE_GA_MEASUREMENT_ID: Boolean(hostedConfig.gaMeasurementId),
@@ -4890,6 +4931,15 @@ function getExpectedOperatorKey() {
4890
4931
  return key || null;
4891
4932
  }
4892
4933
 
4934
+ function getHumanReviewerConfig(expectedApiKey, expectedOperatorKey) {
4935
+ const key = String(process.env.THUMBGATE_HUMAN_REVIEWER_KEY || '').trim();
4936
+ const id = String(process.env.THUMBGATE_HUMAN_REVIEWER_ID || '').trim();
4937
+ const independent = Boolean(key)
4938
+ && !safeKeyEqual(key, expectedApiKey)
4939
+ && !safeKeyEqual(key, expectedOperatorKey);
4940
+ return { key: key || null, id: id || null, independent };
4941
+ }
4942
+
4893
4943
  function isAuthorized(req, expected) {
4894
4944
  if (!expected) return true;
4895
4945
  const token = extractApiKey(req);
@@ -4957,6 +5007,21 @@ function safeKeyEqual(a, b) {
4957
5007
  return require('crypto').timingSafeEqual(x, y);
4958
5008
  }
4959
5009
 
5010
+ function authenticateHumanReviewer(req, reviewerConfig) {
5011
+ if (!reviewerConfig.key || !reviewerConfig.id || !reviewerConfig.independent) {
5012
+ throw createHttpError(503, 'Human escalation decisions are unavailable until an independent reviewer key and identity are configured');
5013
+ }
5014
+ const header = req.headers['x-thumbgate-human-reviewer-key'];
5015
+ const presented = Array.isArray(header) ? header[0] : header;
5016
+ if (!safeKeyEqual(presented, reviewerConfig.key)) {
5017
+ throw createHttpError(403, 'A valid human reviewer credential is required to decide an escalation');
5018
+ }
5019
+ return {
5020
+ id: reviewerConfig.id,
5021
+ kind: 'human',
5022
+ };
5023
+ }
5024
+
4960
5025
  function resolveKeyRole(key) {
4961
5026
  const k = String(key || '').trim();
4962
5027
  const adminKey = String(process.env.THUMBGATE_API_KEY || '').trim();
@@ -5143,6 +5208,7 @@ function resolveDocumentImportFilePath(inputPath, options = {}) {
5143
5208
  function createApiServer() {
5144
5209
  const expectedApiKey = getExpectedApiKey();
5145
5210
  const expectedOperatorKey = getExpectedOperatorKey();
5211
+ const humanReviewerConfig = getHumanReviewerConfig(expectedApiKey, expectedOperatorKey);
5146
5212
 
5147
5213
  const eventBus = new EventEmitter();
5148
5214
  eventBus.setMaxListeners(200);
@@ -5233,6 +5299,14 @@ function createApiServer() {
5233
5299
  ? mcpOauth.tokenAudienceValid(oauthSession, resourceUrl)
5234
5300
  : rawKeyValid;
5235
5301
  if (!authed) {
5302
+ recordToolCall({
5303
+ toolName: msg.params?.name || 'unknown',
5304
+ serverName: 'mcp-http',
5305
+ latencyMs: 0,
5306
+ success: false,
5307
+ agentId: 'unauthenticated',
5308
+ metadata: { denied: true, deniedReason: 'authentication_required' },
5309
+ });
5236
5310
  res.writeHead(401, {
5237
5311
  'Content-Type': 'application/json',
5238
5312
  // RFC 9728: point unauthenticated clients at the resource metadata.
@@ -5255,6 +5329,14 @@ function createApiServer() {
5255
5329
  const tool = MCP_TOOLS.find((t) => t.name === name);
5256
5330
  const readOnly = Boolean(tool && tool.annotations && tool.annotations.readOnlyHint === true);
5257
5331
  if (!readOnly) {
5332
+ recordToolCall({
5333
+ toolName: name || 'unknown',
5334
+ serverName: 'mcp-http',
5335
+ latencyMs: 0,
5336
+ success: false,
5337
+ agentId: 'reviewer',
5338
+ metadata: { denied: true, deniedReason: 'reviewer_read_only' },
5339
+ });
5258
5340
  sendJson(res, 200, {
5259
5341
  jsonrpc: '2.0', id: msg.id,
5260
5342
  error: { code: -32002, message: `Tool "${name}" requires write access; the reviewer credential is read-only.` },
@@ -5262,15 +5344,39 @@ function createApiServer() {
5262
5344
  return;
5263
5345
  }
5264
5346
  }
5347
+ if (oauthSession) {
5348
+ const name = msg.params && msg.params.name;
5349
+ const tool = MCP_TOOLS.find((candidate) => candidate.name === name);
5350
+ const requiredScope = tool?.annotations?.readOnlyHint === true ? 'mcp:read' : 'mcp:write';
5351
+ if (!mcpOauth.scopeAllows(oauthSession, requiredScope)) {
5352
+ recordToolCall({
5353
+ toolName: name || 'unknown',
5354
+ serverName: 'mcp-http',
5355
+ latencyMs: 0,
5356
+ success: false,
5357
+ agentId: oauthSession.clientId || 'oauth-client',
5358
+ metadata: {
5359
+ denied: true,
5360
+ deniedReason: 'insufficient_scope',
5361
+ requiredScope,
5362
+ },
5363
+ });
5364
+ sendJson(res, 200, {
5365
+ jsonrpc: '2.0', id: msg.id,
5366
+ error: { code: -32003, message: `OAuth token is missing required scope "${requiredScope}".` },
5367
+ });
5368
+ return;
5369
+ }
5370
+ }
5265
5371
  (async () => {
5266
5372
  try {
5267
5373
  const { callTool } = require('../../adapters/mcp/server-stdio');
5268
- const name = msg.params && msg.params.name;
5374
+ const name = msg.params?.name;
5269
5375
  const args = (msg.params && msg.params.arguments) || {};
5270
5376
  const result = await callTool(name, args);
5271
5377
  sendJson(res, 200, {
5272
5378
  jsonrpc: '2.0', id: msg.id,
5273
- result: { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }] },
5379
+ result,
5274
5380
  });
5275
5381
  } catch (err) {
5276
5382
  sendJson(res, 200, {
@@ -5553,7 +5659,7 @@ function createApiServer() {
5553
5659
  let trackedParsed = parsed;
5554
5660
  if (req.method === 'POST') {
5555
5661
  const target = getTrackedLinkTarget(trackedLinkMatch[1]);
5556
- if (!target || !target.requiresPost) {
5662
+ if (!target?.requiresPost) {
5557
5663
  sendJson(res, target ? 405 : 404, target
5558
5664
  ? { error: 'POST is not supported for this tracked link' }
5559
5665
  : { error: 'Tracked link not found', allowed: Object.keys(TRACKED_LINK_TARGETS) }, {
@@ -5572,7 +5678,7 @@ function createApiServer() {
5572
5678
  const form = await parseFormBody(req, 16 * 1024);
5573
5679
  trackedParsed = new URL(parsed.toString());
5574
5680
  for (const [key, value] of Object.entries(form)) {
5575
- if (key === 'customer_email' || TRACKED_LINK_QUERY_KEYS.includes(key)) {
5681
+ if (key === 'customer_email' || TRACKED_LINK_QUERY_KEYS.has(key)) {
5576
5682
  trackedParsed.searchParams.set(key, String(value || '').trim());
5577
5683
  }
5578
5684
  }
@@ -5644,10 +5750,31 @@ function createApiServer() {
5644
5750
  return;
5645
5751
  }
5646
5752
 
5647
- // Quick feedback capture via GET — for statusline clickable links
5648
- if (isGetLikeRequest && pathname === '/feedback/quick') {
5753
+ // GET/HEAD only render a confirmation. Capture requires an authenticated
5754
+ // POST so prefetchers and link checkers cannot create human feedback.
5755
+ if ((isGetLikeRequest || req.method === 'POST') && pathname === '/feedback/quick') {
5649
5756
  const signal = parsed.searchParams.get('signal');
5650
5757
  if (signal === 'up' || signal === 'down') {
5758
+ const emoji = signal === 'up' ? '👍' : '👎';
5759
+ const label = signal === 'up' ? 'Positive' : 'Negative';
5760
+ if (isHeadRequest) {
5761
+ sendHtml(res, 200, '', {}, { headOnly: true });
5762
+ return;
5763
+ }
5764
+ if (req.method === 'GET') {
5765
+ sendHtml(res, 200, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ThumbGate feedback</title></head>
5766
+ <body style="background:#0a0a0a;color:#fff;font-family:system-ui;display:grid;place-items:center;height:100vh;margin:0">
5767
+ <form method="post" action="/feedback/quick?signal=${signal}" style="text-align:center;background:#141414;padding:40px;border-radius:16px">
5768
+ <div style="font-size:64px">${emoji}</div><h1>Record ${label.toLowerCase()} feedback?</h1>
5769
+ <p>This confirmation prevents previews and crawlers from recording feedback.</p>
5770
+ <button type="submit" style="padding:10px 20px;font-weight:700">Record ${emoji} feedback</button>
5771
+ </form></body></html>`);
5772
+ return;
5773
+ }
5774
+ if (!isAuthorized(req, expectedApiKey)) {
5775
+ sendJson(res, 401, { error: 'Authentication required to record human feedback' });
5776
+ return;
5777
+ }
5651
5778
  const chatHistory = readRecentConversationWindow({
5652
5779
  feedbackDir: requestSafeDataDir,
5653
5780
  limit: 10,
@@ -5657,35 +5784,17 @@ function createApiServer() {
5657
5784
  context: 'Quick capture from Claude Code statusline',
5658
5785
  chatHistory,
5659
5786
  tags: ['statusline', 'quick-capture'],
5787
+ reviewOrigin: 'human',
5660
5788
  });
5661
- const emoji = signal === 'up' ? '👍' : '👎';
5662
- const color = signal === 'up' ? '#22c55e' : '#ef4444';
5663
- const label = signal === 'up' ? 'Positive' : 'Negative';
5664
5789
  const opposite = signal === 'up' ? 'down' : 'up';
5665
5790
  const oppEmoji = signal === 'up' ? '👎' : '👍';
5666
5791
  const feedbackId = result.feedbackEvent?.id || 'saved';
5667
5792
  const promoted = result.accepted ? 'Promoted to memory' : 'Stored';
5668
5793
  sendHtml(res, 200, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ThumbGate — ${label} feedback</title>
5669
5794
  <style>
5670
- *{box-sizing:border-box}
5671
5795
  body{background:#0a0a0a;color:#fff;font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
5672
5796
  .card{text-align:center;background:#141414;border:1px solid #222;border-radius:16px;padding:48px 40px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,.5)}
5673
- .emoji{font-size:80px;margin-bottom:12px;animation:pop .4s ease-out}
5674
- @keyframes pop{0%{transform:scale(0)}50%{transform:scale(1.2)}100%{transform:scale(1)}}
5675
- .msg{font-size:22px;color:${color};font-weight:700;margin-bottom:4px}
5676
- .sub{font-size:13px;color:#666;margin-top:6px;font-family:ui-monospace,monospace}
5677
- .context-form{margin-top:20px;text-align:left}
5678
- .context-form label{font-size:12px;color:#888;display:block;margin-bottom:6px}
5679
- .context-form textarea{width:100%;background:#1a1a1a;border:1px solid #333;border-radius:8px;color:#ccc;padding:10px;font-size:13px;resize:vertical;min-height:60px;font-family:system-ui}
5680
- .context-form textarea:focus{outline:none;border-color:${color}}
5681
- .context-form button{margin-top:8px;background:${color};color:#000;border:none;border-radius:8px;padding:8px 20px;font-size:13px;font-weight:600;cursor:pointer}
5682
- .context-form button:hover{opacity:.85}
5683
- .actions{margin-top:24px;display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
5684
- .actions a{color:#22d3ee;text-decoration:none;font-size:13px;padding:8px 16px;border:1px solid #333;border-radius:8px;transition:all .15s}
5685
- .actions a:hover{background:#1a2a2e;border-color:#22d3ee}
5686
- .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#22c55e;color:#000;padding:10px 24px;border-radius:8px;font-size:14px;font-weight:600;display:none;animation:slideUp .3s ease-out}
5687
- @keyframes slideUp{from{opacity:0;transform:translateX(-50%) translateY(12px)}to{opacity:1;transform:translateX(-50%) translateY(0)}}
5688
- .badge{display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;background:#1a1a1a;border:1px solid #333;color:#888;margin-top:8px}
5797
+ .emoji{font-size:64px}.sub,label{color:#888}textarea{width:100%;min-height:60px}.actions{margin-top:20px}.actions a{color:#22d3ee;margin:8px}
5689
5798
  </style></head><body>
5690
5799
  <div class="card">
5691
5800
  <div class="emoji">${emoji}</div>
@@ -5698,7 +5807,7 @@ body{background:#0a0a0a;color:#fff;font-family:system-ui,-apple-system,sans-seri
5698
5807
  </div>
5699
5808
  <div class="actions">
5700
5809
  <a href="/lessons/${feedbackId}" title="View the full lesson and edit it">📋 View Lesson</a>
5701
- <a href="/feedback/quick?signal=${opposite}" title="Meant to click ${oppEmoji}?">Undo → send ${oppEmoji} instead</a>
5810
+ <a href="/feedback/quick?signal=${opposite}" title="Meant to click ${oppEmoji}?">Record ${oppEmoji} instead</a>
5702
5811
  <a href="/dashboard">Dashboard →</a>
5703
5812
  </div>
5704
5813
  </div>
@@ -5722,6 +5831,10 @@ async function addContext(){
5722
5831
  }
5723
5832
 
5724
5833
  if (req.method === 'POST' && pathname === '/feedback/quick/context') {
5834
+ if (!isAuthorized(req, expectedApiKey)) {
5835
+ sendJson(res, 401, { error: 'Authentication required to update human feedback' });
5836
+ return;
5837
+ }
5725
5838
  const body = await parseJsonBody(req);
5726
5839
  const signal = body.signal;
5727
5840
  const context = typeof body.context === 'string' ? body.context.trim() : '';
@@ -6166,6 +6279,74 @@ async function addContext(){
6166
6279
  return;
6167
6280
  }
6168
6281
 
6282
+ if (isGetLikeRequest && (pathname === '/architecture' || pathname === '/architecture.html')) {
6283
+ try {
6284
+ servePublicMarketingPage({
6285
+ req,
6286
+ res,
6287
+ parsed,
6288
+ hostedConfig,
6289
+ isHeadRequest,
6290
+ renderHtml: () => fs.readFileSync(ARCHITECTURE_PAGE_PATH, 'utf-8'),
6291
+ extraTelemetry: { pageType: 'architecture' },
6292
+ });
6293
+ } catch {
6294
+ sendJson(res, 404, { error: 'Architecture page not found' });
6295
+ }
6296
+ return;
6297
+ }
6298
+
6299
+ if (isGetLikeRequest && (pathname === '/whitepaper' || pathname === '/whitepaper.html' || pathname === '/how-we-know' || pathname === '/how-we-know-it-works')) {
6300
+ try {
6301
+ servePublicMarketingPage({
6302
+ req,
6303
+ res,
6304
+ parsed,
6305
+ hostedConfig,
6306
+ isHeadRequest,
6307
+ renderHtml: () => fs.readFileSync(WHITEPAPER_PAGE_PATH, 'utf-8'),
6308
+ extraTelemetry: { pageType: 'whitepaper' },
6309
+ });
6310
+ } catch {
6311
+ sendJson(res, 404, { error: 'White paper page not found' });
6312
+ }
6313
+ return;
6314
+ }
6315
+
6316
+ if (isGetLikeRequest && (pathname === '/eval-scorecard' || pathname === '/eval-scorecard.html' || pathname === '/scorecard' || pathname === '/bench')) {
6317
+ try {
6318
+ servePublicMarketingPage({
6319
+ req,
6320
+ res,
6321
+ parsed,
6322
+ hostedConfig,
6323
+ isHeadRequest,
6324
+ renderHtml: () => fs.readFileSync(EVAL_SCORECARD_PAGE_PATH, 'utf-8'),
6325
+ extraTelemetry: { pageType: 'eval_scorecard' },
6326
+ });
6327
+ } catch {
6328
+ sendJson(res, 404, { error: 'Eval scorecard page not found' });
6329
+ }
6330
+ return;
6331
+ }
6332
+
6333
+ if (isGetLikeRequest && (pathname === '/evaluations' || pathname === '/evaluations.html')) {
6334
+ try {
6335
+ servePublicMarketingPage({
6336
+ req,
6337
+ res,
6338
+ parsed,
6339
+ hostedConfig,
6340
+ isHeadRequest,
6341
+ renderHtml: () => fs.readFileSync(EVALUATIONS_PAGE_PATH, 'utf-8'),
6342
+ extraTelemetry: { pageType: 'evaluations' },
6343
+ });
6344
+ } catch {
6345
+ sendJson(res, 404, { error: 'Evaluations page not found' });
6346
+ }
6347
+ return;
6348
+ }
6349
+
6169
6350
  if (isGetLikeRequest && (pathname === '/federal' || pathname === '/federal.html' || pathname === '/government' || pathname === '/gov')) {
6170
6351
  // Federal lead-gen page. Routed through servePublicMarketingPage so agency
6171
6352
  // arrivals via SBIR / GSA / outbound channels capture UTM attribution and
@@ -6430,7 +6611,7 @@ async function addContext(){
6430
6611
  if (
6431
6612
  key === 'confirm'
6432
6613
  || key === 'customer_email'
6433
- || TRACKED_LINK_QUERY_KEYS.includes(key)
6614
+ || TRACKED_LINK_QUERY_KEYS.has(key)
6434
6615
  ) {
6435
6616
  parsed.searchParams.set(key, String(value || '').trim());
6436
6617
  }
@@ -6946,7 +7127,8 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
6946
7127
  // are configured (production) the key MUST match a configured admin /
6947
7128
  // operator / reviewer key — otherwise OAuth would authenticate nobody.
6948
7129
  // In insecure/dev mode (no keys configured) any non-empty key is accepted.
6949
- if (!resolveKeyRole(form.get('api_key') || '')) {
7130
+ const keyRole = resolveKeyRole(form.get('api_key') || '');
7131
+ if (!keyRole) {
6950
7132
  sendJson(res, 400, { error: 'access_denied', error_description: 'invalid ThumbGate API key' });
6951
7133
  return;
6952
7134
  }
@@ -6956,6 +7138,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
6956
7138
  codeChallenge: authorizationParams.codeChallenge,
6957
7139
  codeChallengeMethod: authorizationParams.codeChallengeMethod,
6958
7140
  scope: authorizationParams.scope,
7141
+ allowedScopes: keyRole === 'reviewer' ? ['mcp:read'] : mcpOauth.SUPPORTED_SCOPES,
6959
7142
  resource: authorizationParams.resource || buildPublicUrl(hostedConfig, '/mcp'),
6960
7143
  boundKey: form.get('api_key') || '',
6961
7144
  state,
@@ -6999,7 +7182,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
6999
7182
  sendJson(res, 200, {
7000
7183
  name: 'thumbgate',
7001
7184
  version: pkg.version,
7002
- count: MCP_TOOLS.length,
7185
+ count: getPublicMcpTools().length,
7003
7186
  tools: getToolDiscoveryIndex(hostedConfig),
7004
7187
  }, {}, {
7005
7188
  headOnly: isHeadRequest,
@@ -7032,7 +7215,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7032
7215
  });
7033
7216
  return;
7034
7217
  }
7035
- const tool = MCP_TOOLS.find((candidate) => candidate.name === toolName);
7218
+ const tool = getPublicMcpTools().find((candidate) => candidate.name === toolName);
7036
7219
  if (!tool) {
7037
7220
  sendJson(res, 404, {
7038
7221
  error: 'tool_not_found',
@@ -7048,6 +7231,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7048
7231
  description: tool.description,
7049
7232
  annotations: tool.annotations || {},
7050
7233
  inputSchema: tool.inputSchema,
7234
+ ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
7051
7235
  }, {}, {
7052
7236
  headOnly: isHeadRequest,
7053
7237
  });
@@ -8038,29 +8222,25 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8038
8222
  return;
8039
8223
  }
8040
8224
 
8041
- // Public case studies — proof surface for buyers. Conversion-optimization
8042
- // surface that was missing: thumbgate.ai had no /case-studies, so visitors
8043
- // landed on CLI install commands without seeing whether anyone actually
8044
- // got value. First entry is the Aiventyx Teams listing integration: real
8045
- // third-party CTR signal (5/8 clicks before the /go/teams fix, now routed
8046
- // through team intake so scope happens before checkout).
8047
- if (isGetLikeRequest && pathname === '/case-studies') {
8048
- sendHtml(res, 200, `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Case Studies — ThumbGate</title><meta name="description" content="Real integrations of ThumbGate's pre-action checks for AI coding agents. Proof, not promises."><style>body{font-family:system-ui,-apple-system,sans-serif;max-width:780px;margin:0 auto;padding:32px 20px;line-height:1.55;color:#1f2937}h1{font-size:32px;margin:0 0 8px}.lede{color:#6b7280;font-size:18px;margin:0 0 32px}article{border:1px solid #e5e7eb;border-radius:12px;padding:24px;margin-bottom:24px;background:#fff}article h2{margin:0 0 4px;font-size:22px}.meta{color:#6b7280;font-size:13px;margin-bottom:16px}h3{font-size:15px;margin:20px 0 8px;color:#374151;text-transform:uppercase;letter-spacing:0.5px}.metric{display:inline-block;background:#0f172a;color:#fff;padding:4px 10px;border-radius:6px;font-weight:600;font-size:14px;margin:0 4px 4px 0}p{margin:8px 0}a{color:#0066cc}code{background:#f3f4f6;padding:1px 6px;border-radius:4px;font-size:13.5px}footer{margin-top:48px;padding-top:24px;border-top:1px solid #e5e7eb;color:#6b7280;font-size:14px}</style></head><body>
8049
- <h1>Case Studies</h1>
8050
- <p class="lede">Real integrations. No fabricated logos, no aspirational numbers — every claim below is reproducible.</p>
8051
-
8052
- <p><em>New case studies for individual Pro operators coming soon.</em></p>
8053
-
8054
- <footer>
8055
- <p>Want to be the next case study? The product is real, the integration is 30 seconds: <code>npx thumbgate init</code>. If you ship something with ThumbGate and want it documented here, email <a href="mailto:igor.ganapolsky@gmail.com">igor.ganapolsky@gmail.com</a>.</p>
8056
- <p><a href="https://thumbgate.ai">Home</a> · <a href="/pricing">Pricing</a> · <a href="/privacy">Privacy</a> · <a href="/terms">Terms</a> · <a href="/support">Support</a></p>
8057
- </footer>
8058
- </body></html>`, {}, {
8059
- headOnly: isHeadRequest,
8060
- });
8225
+ // Public case studies — dogfood proof narratives (static HTML; no fabricated logos).
8226
+ if (isGetLikeRequest && (pathname === '/case-studies' || pathname === '/case-studies.html')) {
8227
+ try {
8228
+ servePublicMarketingPage({
8229
+ req,
8230
+ res,
8231
+ parsed,
8232
+ hostedConfig,
8233
+ isHeadRequest,
8234
+ renderHtml: () => fs.readFileSync(CASE_STUDIES_PAGE_PATH, 'utf-8'),
8235
+ extraTelemetry: { pageType: 'case_studies' },
8236
+ });
8237
+ } catch {
8238
+ sendJson(res, 404, { error: 'Case studies page not found' });
8239
+ }
8061
8240
  return;
8062
8241
  }
8063
8242
 
8243
+
8064
8244
  // Public canonical pricing page. The audit flagged "pricing schizophrenia":
8065
8245
  // sales/pricing.json said $49 / $299, COMMERCIAL_TRUTH.md said $19 / $149,
8066
8246
  // and there was no buyer-facing surface to reconcile the two. This is now
@@ -8215,7 +8395,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8215
8395
  });
8216
8396
  return;
8217
8397
  }
8218
- if (result && result.retryable === true) {
8398
+ if (result?.retryable === true) {
8219
8399
  sendProblem(res, {
8220
8400
  type: PROBLEM_TYPES.INTERNAL,
8221
8401
  title: 'Fulfillment temporarily unavailable',
@@ -8403,14 +8583,15 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8403
8583
  }
8404
8584
 
8405
8585
  const resolvedTraceId = result.traceId || requestedTraceId;
8406
- const nextSteps = result.apiKey
8407
- ? {
8586
+ let nextSteps = {};
8587
+ if (result.apiKey) {
8588
+ nextSteps = {
8408
8589
  env: `THUMBGATE_API_KEY=${result.apiKey}\nTHUMBGATE_API_BASE_URL=${hostedConfig.billingApiBaseUrl}`,
8409
8590
  curl: `curl -X POST ${hostedConfig.billingApiBaseUrl}/v1/feedback/capture \\\n+ -H 'Authorization: Bearer ${result.apiKey}' \\\n+ -H 'Content-Type: application/json' \\\n+ -d '{"signal":"down","context":"example","whatWentWrong":"example","whatToChange":"example"}'`,
8410
- }
8411
- : result.offerKind === 'workflow_hardening_diagnostic'
8412
- ? { fulfillment: 'Payment confirmed. Diagnostic fulfillment is processing; an email will arrive when it completes.' }
8413
- : {};
8591
+ };
8592
+ } else if (result.offerKind === 'workflow_hardening_diagnostic') {
8593
+ nextSteps = { fulfillment: 'Payment confirmed. Diagnostic fulfillment is processing; an email will arrive when it completes.' };
8594
+ }
8414
8595
 
8415
8596
  sendJson(res, 200, {
8416
8597
  ...result,
@@ -8442,14 +8623,19 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8442
8623
  return;
8443
8624
  }
8444
8625
 
8445
- // Operator key is allowed to bypass the general admin gate for its dedicated endpoint
8626
+ // Operator key is allowed to bypass the general admin gate for dedicated
8627
+ // read-only operational endpoints.
8446
8628
  const _reqToken = extractApiKey(req);
8447
- const isOperatorBillingRequest = Boolean(expectedOperatorKey)
8448
- && _reqToken === expectedOperatorKey
8629
+ const isOperatorReadRequest = Boolean(expectedOperatorKey)
8630
+ && safeKeyEqual(_reqToken, expectedOperatorKey)
8449
8631
  && req.method === 'GET'
8450
- && ['/v1/billing/summary', '/v1/intake/workflow-sprint/queue'].includes(pathname);
8632
+ && [
8633
+ '/v1/billing/summary',
8634
+ '/v1/intake/workflow-sprint/queue',
8635
+ '/v1/task-outcomes/monitor',
8636
+ ].includes(pathname);
8451
8637
 
8452
- if (!isOperatorBillingRequest && !isAuthorized(req, expectedApiKey)) {
8638
+ if (!isOperatorReadRequest && !isAuthorized(req, expectedApiKey)) {
8453
8639
  sendProblem(res, {
8454
8640
  type: PROBLEM_TYPES.UNAUTHORIZED,
8455
8641
  title: 'Unauthorized',
@@ -8469,7 +8655,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8469
8655
  if (req.method === 'GET' && pathname === '/v1/feedback/stats') {
8470
8656
  const stats = shouldAggregateFeedback()
8471
8657
  ? computeAggregateFeedbackStats({ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR })
8472
- : analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH);
8658
+ : analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH, { humanOnly: true });
8473
8659
  try {
8474
8660
  const { getStatuslineMeta } = require('../../scripts/statusline-meta');
8475
8661
  const meta = getStatuslineMeta({ env: process.env });
@@ -8989,6 +9175,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8989
9175
  workflowContract: body.workflowContract,
8990
9176
  repoPath: body.repoPath,
8991
9177
  localOnly: body.localOnly === true,
9178
+ ttlMs: body.ttlMs,
8992
9179
  clear: body.clear === true,
8993
9180
  });
8994
9181
  sendJson(res, 200, { scope });
@@ -9037,6 +9224,84 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9037
9224
  return;
9038
9225
  }
9039
9226
 
9227
+ if (req.method === 'POST' && pathname === '/v1/task-outcomes') {
9228
+ const body = await parseJsonBody(req);
9229
+ try {
9230
+ const result = recordTaskOutcome(body, { feedbackDir: requestFeedbackDir });
9231
+ sendJson(res, result.recorded ? 201 : 200, result);
9232
+ } catch (error) {
9233
+ throw createHttpError(400, error.message);
9234
+ }
9235
+ return;
9236
+ }
9237
+
9238
+ if (req.method === 'GET' && pathname === '/v1/task-outcomes') {
9239
+ const taskId = parsed.searchParams.get('taskId');
9240
+ const limit = Math.max(1, Math.min(100, Number(parsed.searchParams.get('limit') || 20)));
9241
+ if (taskId) {
9242
+ const outcome = getTaskOutcome(taskId, { feedbackDir: requestFeedbackDir });
9243
+ if (!outcome) throw createHttpError(404, `Task outcome not found: ${taskId}`);
9244
+ sendJson(res, 200, outcome);
9245
+ } else {
9246
+ sendJson(res, 200, {
9247
+ outcomes: readTaskOutcomes({ feedbackDir: requestFeedbackDir }).slice(-limit),
9248
+ });
9249
+ }
9250
+ return;
9251
+ }
9252
+
9253
+ if (req.method === 'GET' && pathname === '/v1/task-outcomes/metrics') {
9254
+ const outcomes = readTaskOutcomes({ feedbackDir: requestFeedbackDir });
9255
+ sendJson(res, 200, calculateTaskOutcomeMetrics(outcomes));
9256
+ return;
9257
+ }
9258
+
9259
+ if (req.method === 'GET' && pathname === '/v1/task-outcomes/monitor') {
9260
+ const outcomes = readTaskOutcomes({ feedbackDir: requestFeedbackDir });
9261
+ const toolKpis = computeToolKpis({ feedbackDir: requestFeedbackDir });
9262
+ sendJson(res, 200, monitorProductionSignals(outcomes, toolKpis));
9263
+ return;
9264
+ }
9265
+
9266
+ if (req.method === 'POST' && pathname === '/v1/escalations') {
9267
+ const body = await parseJsonBody(req);
9268
+ try {
9269
+ const result = requestEscalation(body, { feedbackDir: requestFeedbackDir });
9270
+ sendJson(res, result.recorded ? 201 : 200, result);
9271
+ } catch (error) {
9272
+ throw createHttpError(400, error.message);
9273
+ }
9274
+ return;
9275
+ }
9276
+
9277
+ if (req.method === 'GET' && pathname === '/v1/escalations') {
9278
+ const status = parsed.searchParams.get('status') || undefined;
9279
+ const escalations = listEscalations({ feedbackDir: requestFeedbackDir, status });
9280
+ sendJson(res, 200, {
9281
+ escalations,
9282
+ metrics: calculateEscalationMetrics(escalations),
9283
+ });
9284
+ return;
9285
+ }
9286
+
9287
+ if (req.method === 'POST' && /^\/v1\/escalations\/[^/]+\/decision$/.test(pathname)) {
9288
+ const escalationId = decodeURIComponent(pathname.split('/')[3]);
9289
+ const authenticatedActor = authenticateHumanReviewer(req, humanReviewerConfig);
9290
+ const body = await parseJsonBody(req);
9291
+ try {
9292
+ sendJson(res, 200, decideEscalation({
9293
+ ...body,
9294
+ escalationId,
9295
+ }, {
9296
+ authenticatedActor,
9297
+ feedbackDir: requestFeedbackDir,
9298
+ }));
9299
+ } catch (error) {
9300
+ throw createHttpError(400, error.message);
9301
+ }
9302
+ return;
9303
+ }
9304
+
9040
9305
  if (req.method === 'GET' && pathname === '/v1/ops/integrity') {
9041
9306
  const command = parsed.searchParams.get('command') || undefined;
9042
9307
  const baseBranch = parsed.searchParams.get('baseBranch') || undefined;
@@ -9207,6 +9472,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9207
9472
  guardrails: body.guardrails,
9208
9473
  tags: extractTags(body.tags),
9209
9474
  skill: body.skill,
9475
+ reviewOrigin: 'human',
9210
9476
  });
9211
9477
  const actionIntegration = inferActionIntegration(body, req.headers);
9212
9478
  appendBestEffortTelemetry(requestFeedbackDir, {