@nforma.ai/nforma 0.2.1

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 (215) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +1024 -0
  3. package/agents/qgsd-codebase-mapper.md +764 -0
  4. package/agents/qgsd-debugger.md +1201 -0
  5. package/agents/qgsd-executor.md +472 -0
  6. package/agents/qgsd-integration-checker.md +443 -0
  7. package/agents/qgsd-phase-researcher.md +502 -0
  8. package/agents/qgsd-plan-checker.md +643 -0
  9. package/agents/qgsd-planner.md +1182 -0
  10. package/agents/qgsd-project-researcher.md +621 -0
  11. package/agents/qgsd-quorum-orchestrator.md +628 -0
  12. package/agents/qgsd-quorum-slot-worker.md +41 -0
  13. package/agents/qgsd-quorum-synthesizer.md +133 -0
  14. package/agents/qgsd-quorum-test-worker.md +37 -0
  15. package/agents/qgsd-quorum-worker.md +161 -0
  16. package/agents/qgsd-research-synthesizer.md +239 -0
  17. package/agents/qgsd-roadmapper.md +660 -0
  18. package/agents/qgsd-verifier.md +628 -0
  19. package/bin/accept-debug-invariant.cjs +165 -0
  20. package/bin/account-manager.cjs +719 -0
  21. package/bin/aggregate-requirements.cjs +466 -0
  22. package/bin/analyze-assumptions.cjs +757 -0
  23. package/bin/analyze-state-space.cjs +921 -0
  24. package/bin/attribute-trace-divergence.cjs +150 -0
  25. package/bin/auth-drivers/gh-cli.cjs +93 -0
  26. package/bin/auth-drivers/index.cjs +46 -0
  27. package/bin/auth-drivers/pool.cjs +67 -0
  28. package/bin/auth-drivers/simple.cjs +95 -0
  29. package/bin/autoClosePtoF.cjs +110 -0
  30. package/bin/blessed-terminal.cjs +350 -0
  31. package/bin/build-phase-index.cjs +472 -0
  32. package/bin/call-quorum-slot.cjs +541 -0
  33. package/bin/ccr-secure-config.cjs +99 -0
  34. package/bin/ccr-secure-start.cjs +83 -0
  35. package/bin/check-bundled-sdks.cjs +177 -0
  36. package/bin/check-coverage-guard.cjs +112 -0
  37. package/bin/check-liveness-fairness.cjs +95 -0
  38. package/bin/check-mcp-health.cjs +123 -0
  39. package/bin/check-provider-health.cjs +395 -0
  40. package/bin/check-results-exit.cjs +24 -0
  41. package/bin/check-spec-sync.cjs +360 -0
  42. package/bin/check-trace-redaction.cjs +271 -0
  43. package/bin/check-trace-schema-drift.cjs +99 -0
  44. package/bin/compareDrift.cjs +21 -0
  45. package/bin/conformance-schema.cjs +12 -0
  46. package/bin/count-scenarios.cjs +420 -0
  47. package/bin/debt-dedup.cjs +144 -0
  48. package/bin/debt-ledger.cjs +61 -0
  49. package/bin/debt-retention.cjs +76 -0
  50. package/bin/debt-state-machine.cjs +80 -0
  51. package/bin/detect-coverage-gaps.cjs +204 -0
  52. package/bin/detect-project-intent.cjs +362 -0
  53. package/bin/export-prism-constants.cjs +164 -0
  54. package/bin/extract-annotations.cjs +633 -0
  55. package/bin/extractFormalExpected.cjs +104 -0
  56. package/bin/fingerprint-drift.cjs +24 -0
  57. package/bin/fingerprint-issue.cjs +46 -0
  58. package/bin/formal-core.cjs +519 -0
  59. package/bin/formal-ref-linker.cjs +141 -0
  60. package/bin/formal-test-sync.cjs +788 -0
  61. package/bin/generate-formal-specs.cjs +588 -0
  62. package/bin/generate-petri-net.cjs +397 -0
  63. package/bin/generate-phase-spec.cjs +249 -0
  64. package/bin/generate-proposed-changes.cjs +194 -0
  65. package/bin/generate-tla-cfg.cjs +122 -0
  66. package/bin/generate-traceability-matrix.cjs +701 -0
  67. package/bin/generate-triage-bundle.cjs +300 -0
  68. package/bin/gh-account-rotate.cjs +34 -0
  69. package/bin/initialize-model-registry.cjs +105 -0
  70. package/bin/install-formal-tools.cjs +382 -0
  71. package/bin/install.js +2424 -0
  72. package/bin/isNumericThreshold.cjs +34 -0
  73. package/bin/issue-classifier.cjs +151 -0
  74. package/bin/levenshtein.cjs +74 -0
  75. package/bin/lint-formal-models.cjs +580 -0
  76. package/bin/load-baseline-requirements.cjs +275 -0
  77. package/bin/manage-agents-core.cjs +815 -0
  78. package/bin/migrate-formal-dir.cjs +172 -0
  79. package/bin/migrate-planning.cjs +206 -0
  80. package/bin/migrate-to-slots.cjs +255 -0
  81. package/bin/nForma.cjs +2726 -0
  82. package/bin/observe-config.cjs +353 -0
  83. package/bin/observe-debt-writer.cjs +140 -0
  84. package/bin/observe-handler-grafana.cjs +128 -0
  85. package/bin/observe-handler-internal.cjs +301 -0
  86. package/bin/observe-handler-logstash.cjs +153 -0
  87. package/bin/observe-handler-prometheus.cjs +185 -0
  88. package/bin/observe-handlers.cjs +436 -0
  89. package/bin/observe-registry.cjs +131 -0
  90. package/bin/observe-render.cjs +168 -0
  91. package/bin/planning-paths.cjs +167 -0
  92. package/bin/polyrepo.cjs +560 -0
  93. package/bin/prism-priority.cjs +153 -0
  94. package/bin/probe-quorum-slots.cjs +167 -0
  95. package/bin/promote-model.cjs +225 -0
  96. package/bin/propose-debug-invariants.cjs +165 -0
  97. package/bin/providers.json +392 -0
  98. package/bin/pty-proxy.py +129 -0
  99. package/bin/qgsd-solve.cjs +2477 -0
  100. package/bin/quorum-consensus-gate.cjs +238 -0
  101. package/bin/quorum-formal-context.cjs +183 -0
  102. package/bin/quorum-slot-dispatch.cjs +934 -0
  103. package/bin/read-policy.cjs +60 -0
  104. package/bin/requirement-map.cjs +63 -0
  105. package/bin/requirements-core.cjs +247 -0
  106. package/bin/resolve-cli.cjs +101 -0
  107. package/bin/review-mcp-logs.cjs +294 -0
  108. package/bin/run-account-manager-tlc.cjs +188 -0
  109. package/bin/run-account-pool-alloy.cjs +158 -0
  110. package/bin/run-alloy.cjs +153 -0
  111. package/bin/run-audit-alloy.cjs +187 -0
  112. package/bin/run-breaker-tlc.cjs +181 -0
  113. package/bin/run-formal-check.cjs +395 -0
  114. package/bin/run-formal-verify.cjs +701 -0
  115. package/bin/run-installer-alloy.cjs +188 -0
  116. package/bin/run-oauth-rotation-prism.cjs +132 -0
  117. package/bin/run-oscillation-tlc.cjs +202 -0
  118. package/bin/run-phase-tlc.cjs +228 -0
  119. package/bin/run-prism.cjs +446 -0
  120. package/bin/run-protocol-tlc.cjs +201 -0
  121. package/bin/run-quorum-composition-alloy.cjs +155 -0
  122. package/bin/run-sensitivity-sweep.cjs +231 -0
  123. package/bin/run-stop-hook-tlc.cjs +188 -0
  124. package/bin/run-tlc.cjs +467 -0
  125. package/bin/run-transcript-alloy.cjs +173 -0
  126. package/bin/run-uppaal.cjs +264 -0
  127. package/bin/secrets.cjs +134 -0
  128. package/bin/sensitivity-report.cjs +219 -0
  129. package/bin/sensitivity-sweep-feedback.cjs +194 -0
  130. package/bin/set-secret.cjs +29 -0
  131. package/bin/setup-telemetry-cron.sh +36 -0
  132. package/bin/sweepPtoF.cjs +63 -0
  133. package/bin/sync-baseline-requirements.cjs +290 -0
  134. package/bin/task-envelope.cjs +360 -0
  135. package/bin/telemetry-collector.cjs +229 -0
  136. package/bin/unified-mcp-server.mjs +735 -0
  137. package/bin/update-agents.cjs +369 -0
  138. package/bin/update-scoreboard.cjs +1134 -0
  139. package/bin/validate-debt-entry.cjs +207 -0
  140. package/bin/validate-invariant.cjs +419 -0
  141. package/bin/validate-memory.cjs +389 -0
  142. package/bin/validate-requirements-haiku.cjs +435 -0
  143. package/bin/validate-traces.cjs +438 -0
  144. package/bin/verify-formal-results.cjs +124 -0
  145. package/bin/verify-quorum-health.cjs +273 -0
  146. package/bin/write-check-result.cjs +106 -0
  147. package/bin/xstate-to-tla.cjs +483 -0
  148. package/bin/xstate-trace-walker.cjs +205 -0
  149. package/commands/qgsd/add-phase.md +43 -0
  150. package/commands/qgsd/add-requirement.md +24 -0
  151. package/commands/qgsd/add-todo.md +47 -0
  152. package/commands/qgsd/audit-milestone.md +37 -0
  153. package/commands/qgsd/check-todos.md +45 -0
  154. package/commands/qgsd/cleanup.md +18 -0
  155. package/commands/qgsd/close-formal-gaps.md +33 -0
  156. package/commands/qgsd/complete-milestone.md +136 -0
  157. package/commands/qgsd/debug.md +166 -0
  158. package/commands/qgsd/discuss-phase.md +83 -0
  159. package/commands/qgsd/execute-phase.md +117 -0
  160. package/commands/qgsd/fix-tests.md +27 -0
  161. package/commands/qgsd/formal-test-sync.md +32 -0
  162. package/commands/qgsd/health.md +22 -0
  163. package/commands/qgsd/help.md +22 -0
  164. package/commands/qgsd/insert-phase.md +32 -0
  165. package/commands/qgsd/join-discord.md +18 -0
  166. package/commands/qgsd/list-phase-assumptions.md +46 -0
  167. package/commands/qgsd/map-codebase.md +71 -0
  168. package/commands/qgsd/map-requirements.md +20 -0
  169. package/commands/qgsd/mcp-restart.md +176 -0
  170. package/commands/qgsd/mcp-set-model.md +134 -0
  171. package/commands/qgsd/mcp-setup.md +1371 -0
  172. package/commands/qgsd/mcp-status.md +274 -0
  173. package/commands/qgsd/mcp-update.md +238 -0
  174. package/commands/qgsd/new-milestone.md +44 -0
  175. package/commands/qgsd/new-project.md +42 -0
  176. package/commands/qgsd/observe.md +260 -0
  177. package/commands/qgsd/pause-work.md +38 -0
  178. package/commands/qgsd/plan-milestone-gaps.md +34 -0
  179. package/commands/qgsd/plan-phase.md +44 -0
  180. package/commands/qgsd/polyrepo.md +50 -0
  181. package/commands/qgsd/progress.md +24 -0
  182. package/commands/qgsd/queue.md +54 -0
  183. package/commands/qgsd/quick.md +133 -0
  184. package/commands/qgsd/quorum-test.md +275 -0
  185. package/commands/qgsd/quorum.md +707 -0
  186. package/commands/qgsd/reapply-patches.md +110 -0
  187. package/commands/qgsd/remove-phase.md +31 -0
  188. package/commands/qgsd/research-phase.md +189 -0
  189. package/commands/qgsd/resume-work.md +40 -0
  190. package/commands/qgsd/set-profile.md +34 -0
  191. package/commands/qgsd/settings.md +39 -0
  192. package/commands/qgsd/solve.md +565 -0
  193. package/commands/qgsd/sync-baselines.md +119 -0
  194. package/commands/qgsd/triage.md +233 -0
  195. package/commands/qgsd/update.md +37 -0
  196. package/commands/qgsd/verify-work.md +38 -0
  197. package/hooks/dist/config-loader.js +297 -0
  198. package/hooks/dist/conformance-schema.cjs +12 -0
  199. package/hooks/dist/gsd-context-monitor.js +64 -0
  200. package/hooks/dist/qgsd-check-update.js +62 -0
  201. package/hooks/dist/qgsd-circuit-breaker.js +682 -0
  202. package/hooks/dist/qgsd-precompact.js +156 -0
  203. package/hooks/dist/qgsd-prompt.js +653 -0
  204. package/hooks/dist/qgsd-session-start.js +122 -0
  205. package/hooks/dist/qgsd-slot-correlator.js +58 -0
  206. package/hooks/dist/qgsd-spec-regen.js +86 -0
  207. package/hooks/dist/qgsd-statusline.js +91 -0
  208. package/hooks/dist/qgsd-stop.js +553 -0
  209. package/hooks/dist/qgsd-token-collector.js +133 -0
  210. package/hooks/dist/unified-mcp-server.mjs +669 -0
  211. package/package.json +95 -0
  212. package/scripts/build-hooks.js +46 -0
  213. package/scripts/postinstall.js +48 -0
  214. package/scripts/secret-audit.sh +45 -0
  215. package/templates/qgsd.json +49 -0
@@ -0,0 +1,436 @@
1
+ /**
2
+ * Source handler implementations for /qgsd:observe
3
+ * GitHub, Sentry, sentry-feedback, and bash handlers
4
+ *
5
+ * ALL handlers return the SAME schema:
6
+ * { source_label, source_type, status: "ok"|"error"|"pending_mcp", issues: [...], error?, _mcp_instruction? }
7
+ *
8
+ * GitHub and bash handlers do their work directly (CLI calls via execFileSync).
9
+ * Sentry and sentry-feedback return status: "pending_mcp" with _mcp_instruction.
10
+ */
11
+
12
+ const { execFileSync } = require('node:child_process');
13
+
14
+ // Severity labels recognized from GitHub labels (ordered by priority)
15
+ const SEVERITY_LABELS = ['critical', 'error', 'bug', 'warning', 'enhancement', 'info'];
16
+
17
+ /**
18
+ * Parse a duration string like "7d", "24h", "30m" into milliseconds
19
+ * @param {string} duration - Duration string
20
+ * @returns {number} Milliseconds
21
+ */
22
+ function parseDuration(duration) {
23
+ if (!duration) return 0;
24
+ const match = String(duration).match(/^(\d+)([dhms])$/);
25
+ if (!match) return 0;
26
+ const num = parseInt(match[1], 10);
27
+ const unit = match[2];
28
+ const multipliers = { d: 86400000, h: 3600000, m: 60000, s: 1000 };
29
+ return num * (multipliers[unit] || 0);
30
+ }
31
+
32
+ /**
33
+ * Format age from ISO date to human-readable string
34
+ * @param {string} isoDate - ISO8601 date string
35
+ * @returns {string} Human-readable age like "5m", "2h", "3d"
36
+ */
37
+ function formatAge(isoDate) {
38
+ if (!isoDate) return 'unknown';
39
+ const diffMs = Date.now() - new Date(isoDate).getTime();
40
+ if (diffMs < 0) return 'future';
41
+ const minutes = Math.floor(diffMs / 60000);
42
+ if (minutes < 60) return `${minutes}m`;
43
+ const hours = Math.floor(minutes / 60);
44
+ if (hours < 24) return `${hours}h`;
45
+ const days = Math.floor(hours / 24);
46
+ return `${days}d`;
47
+ }
48
+
49
+ /**
50
+ * Classify severity from GitHub labels
51
+ * @param {Array} labels - Array of label objects with 'name' field, or strings
52
+ * @returns {string} Severity string
53
+ */
54
+ function classifySeverityFromLabels(labels) {
55
+ if (!Array.isArray(labels)) return 'info';
56
+ const labelNames = labels.map(l => (typeof l === 'string' ? l : l.name || '').toLowerCase());
57
+ for (const sev of SEVERITY_LABELS) {
58
+ if (labelNames.some(name => name.includes(sev))) {
59
+ return sev;
60
+ }
61
+ }
62
+ return 'info';
63
+ }
64
+
65
+ /**
66
+ * Detect repo from git remote
67
+ * @param {Function} [execFn] - execFileSync function for testing
68
+ * @returns {string|null} owner/repo string or null
69
+ */
70
+ function detectRepoFromGit(execFn) {
71
+ const execFile = execFn || execFileSync;
72
+ try {
73
+ const url = execFile('git', ['remote', 'get-url', 'origin'], { encoding: 'utf8' }).trim();
74
+ // Parse SSH: git@github.com:owner/repo.git or HTTPS: https://github.com/owner/repo.git
75
+ const sshMatch = url.match(/github\.com[:/]([^/]+\/[^/.]+)/);
76
+ if (sshMatch) return sshMatch[1];
77
+ return null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * GitHub source handler
85
+ * Uses gh CLI via execFileSync (no shell injection risk) to fetch issues
86
+ *
87
+ * @param {object} sourceConfig - { type, label, repo?, filter?: { state, labels, since, limit } }
88
+ * @param {object} options - { sinceOverride?, limitOverride?, execFn? }
89
+ * @returns {object} Standard schema result
90
+ */
91
+ function handleGitHub(sourceConfig, options) {
92
+ const label = sourceConfig.label || 'GitHub';
93
+ const execFile = options.execFn || execFileSync;
94
+
95
+ try {
96
+ const repo = sourceConfig.repo || detectRepoFromGit(execFile);
97
+ if (!repo) {
98
+ return {
99
+ source_label: label,
100
+ source_type: 'github',
101
+ status: 'error',
102
+ error: 'Could not determine repo — set repo in config or ensure git remote is configured',
103
+ issues: []
104
+ };
105
+ }
106
+
107
+ const filter = sourceConfig.filter || {};
108
+ const state = filter.state || 'open';
109
+ const limit = options.limitOverride || filter.limit || 10;
110
+ const since = options.sinceOverride || filter.since;
111
+ const labels = filter.labels || [];
112
+
113
+ // Build gh CLI args — using execFileSync (array args, no shell interpolation)
114
+ const args = ['issue', 'list', '--repo', repo, '--state', state,
115
+ '--limit', String(limit), '--json', 'number,title,url,labels,createdAt,assignees'];
116
+
117
+ for (const lbl of labels) {
118
+ args.push('--label', lbl);
119
+ }
120
+
121
+ const output = execFile('gh', args, { encoding: 'utf8' });
122
+ let issues = JSON.parse(output);
123
+
124
+ // Apply since filter
125
+ if (since) {
126
+ const cutoffMs = parseDuration(since);
127
+ if (cutoffMs > 0) {
128
+ const cutoff = Date.now() - cutoffMs;
129
+ issues = issues.filter(i => new Date(i.createdAt).getTime() > cutoff);
130
+ }
131
+ }
132
+
133
+ return {
134
+ source_label: label,
135
+ source_type: 'github',
136
+ status: 'ok',
137
+ issues: issues.map(issue => ({
138
+ id: `gh-${issue.number}`,
139
+ title: issue.title,
140
+ severity: classifySeverityFromLabels(issue.labels),
141
+ url: issue.url || '',
142
+ age: formatAge(issue.createdAt),
143
+ created_at: issue.createdAt,
144
+ meta: `#${issue.number} · ${(issue.assignees || []).length} assignee(s)`,
145
+ source_type: 'github',
146
+ issue_type: sourceConfig.issue_type || 'issue'
147
+ }))
148
+ };
149
+ } catch (err) {
150
+ return {
151
+ source_label: label,
152
+ source_type: 'github',
153
+ status: 'error',
154
+ error: `GitHub fetch failed: ${err.message}`,
155
+ issues: []
156
+ };
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Sentry source handler
162
+ * Returns pending_mcp status with instruction for the observe command to execute MCP call
163
+ *
164
+ * @param {object} sourceConfig - { type, label, project?, filter?: { status, since } }
165
+ * @param {object} options - { sinceOverride? }
166
+ * @returns {object} Standard schema result with pending_mcp status
167
+ */
168
+ function handleSentry(sourceConfig, options) {
169
+ const label = sourceConfig.label || 'Sentry';
170
+
171
+ try {
172
+ const project = sourceConfig.project || '';
173
+ const parts = project.split('/');
174
+ const organization_slug = parts[0] || '';
175
+ const project_slug = parts[1] || '';
176
+
177
+ const filter = sourceConfig.filter || {};
178
+ const status = filter.status || 'unresolved';
179
+ const since = options.sinceOverride || filter.since || '24h';
180
+
181
+ return {
182
+ source_label: label,
183
+ source_type: 'sentry',
184
+ status: 'pending_mcp',
185
+ issues: [],
186
+ _mcp_instruction: {
187
+ type: 'mcp',
188
+ tool: 'list_project_issues',
189
+ params: {
190
+ organization_slug,
191
+ project_slug,
192
+ query: `is:${status} firstSeen:>${since}`
193
+ },
194
+ mapper: 'mapSentryIssuesToSchema'
195
+ }
196
+ };
197
+ } catch (err) {
198
+ return {
199
+ source_label: label,
200
+ source_type: 'sentry',
201
+ status: 'error',
202
+ error: `Sentry handler failed: ${err.message}`,
203
+ issues: []
204
+ };
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Map raw Sentry MCP result to standard schema
210
+ * @param {Array} mcpResult - Array of Sentry issue objects from MCP
211
+ * @param {object} sourceConfig - Source config for labels
212
+ * @returns {object} Standard schema result with mapped issues
213
+ */
214
+ function mapSentryIssuesToSchema(mcpResult, sourceConfig) {
215
+ const label = sourceConfig.label || 'Sentry';
216
+ const levelMap = { fatal: 'error', error: 'error', warning: 'warning', info: 'info' };
217
+
218
+ try {
219
+ const issues = (Array.isArray(mcpResult) ? mcpResult : []).map(issue => ({
220
+ id: `sentry-${issue.id}`,
221
+ title: issue.title || issue.culprit || 'Unknown Sentry issue',
222
+ severity: levelMap[issue.level] || 'info',
223
+ url: issue.permalink || '',
224
+ age: formatAge(issue.firstSeen || issue.dateCreated),
225
+ created_at: issue.firstSeen || issue.dateCreated || new Date().toISOString(),
226
+ meta: `${issue.count || 0} events · ${issue.userCount || 0} users`,
227
+ source_type: 'sentry',
228
+ issue_type: sourceConfig.issue_type || 'issue'
229
+ }));
230
+
231
+ return {
232
+ source_label: label,
233
+ source_type: 'sentry',
234
+ status: 'ok',
235
+ issues
236
+ };
237
+ } catch (err) {
238
+ return {
239
+ source_label: label,
240
+ source_type: 'sentry',
241
+ status: 'error',
242
+ error: `Sentry mapping failed: ${err.message}`,
243
+ issues: []
244
+ };
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Sentry feedback source handler
250
+ * Returns pending_mcp status with instruction
251
+ *
252
+ * @param {object} sourceConfig - { type, label, project?, filter?: { since } }
253
+ * @param {object} options - { sinceOverride? }
254
+ * @returns {object} Standard schema result with pending_mcp status
255
+ */
256
+ function handleSentryFeedback(sourceConfig, options) {
257
+ const label = sourceConfig.label || 'Sentry Feedback';
258
+
259
+ try {
260
+ const project = sourceConfig.project || '';
261
+ const parts = project.split('/');
262
+ const organization_slug = parts[0] || '';
263
+ const project_slug = parts[1] || '';
264
+
265
+ return {
266
+ source_label: label,
267
+ source_type: 'sentry-feedback',
268
+ status: 'pending_mcp',
269
+ issues: [],
270
+ _mcp_instruction: {
271
+ type: 'mcp',
272
+ tool: 'list_user_feedback',
273
+ params: {
274
+ organization_slug,
275
+ project_slug
276
+ },
277
+ mapper: 'mapSentryFeedbackToSchema'
278
+ }
279
+ };
280
+ } catch (err) {
281
+ return {
282
+ source_label: label,
283
+ source_type: 'sentry-feedback',
284
+ status: 'error',
285
+ error: `Sentry feedback handler failed: ${err.message}`,
286
+ issues: []
287
+ };
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Map raw Sentry feedback MCP result to standard schema
293
+ * @param {Array} mcpResult - Array of feedback objects from MCP
294
+ * @param {object} sourceConfig - Source config for labels
295
+ * @returns {object} Standard schema result with mapped feedback
296
+ */
297
+ function mapSentryFeedbackToSchema(mcpResult, sourceConfig) {
298
+ const label = sourceConfig.label || 'Sentry Feedback';
299
+
300
+ try {
301
+ const issues = (Array.isArray(mcpResult) ? mcpResult : []).map((fb, idx) => {
302
+ const comment = fb.comments || fb.message || 'No comment';
303
+ const truncated = comment.length > 80 ? comment.slice(0, 80) + '...' : comment;
304
+
305
+ return {
306
+ id: `feedback-${fb.id || idx}`,
307
+ title: `[Feedback] ${truncated}`,
308
+ severity: 'info',
309
+ url: fb.url || '',
310
+ age: formatAge(fb.dateCreated),
311
+ created_at: fb.dateCreated || new Date().toISOString(),
312
+ meta: fb.email ? `by ${fb.email}` : 'anonymous',
313
+ source_type: 'sentry-feedback',
314
+ issue_type: sourceConfig.issue_type || 'issue'
315
+ };
316
+ });
317
+
318
+ return {
319
+ source_label: label,
320
+ source_type: 'sentry-feedback',
321
+ status: 'ok',
322
+ issues
323
+ };
324
+ } catch (err) {
325
+ return {
326
+ source_label: label,
327
+ source_type: 'sentry-feedback',
328
+ status: 'error',
329
+ error: `Sentry feedback mapping failed: ${err.message}`,
330
+ issues: []
331
+ };
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Bash source handler
337
+ * Uses execFileSync with ['sh', '-c', command] — command comes from user's own config
338
+ *
339
+ * @param {object} sourceConfig - { type, label, command, parser?: "lines"|"json" }
340
+ * @param {object} options - { execFn? }
341
+ * @returns {object} Standard schema result
342
+ */
343
+ function handleBash(sourceConfig, options) {
344
+ const label = sourceConfig.label || 'Bash';
345
+ const execFile = options.execFn || execFileSync;
346
+
347
+ try {
348
+ if (!sourceConfig.command) {
349
+ return {
350
+ source_label: label,
351
+ source_type: 'bash',
352
+ status: 'error',
353
+ error: 'No command configured for bash source',
354
+ issues: []
355
+ };
356
+ }
357
+
358
+ // execFileSync with ['sh', '-c', command] — command is from trusted config file
359
+ const output = execFile('sh', ['-c', sourceConfig.command], { encoding: 'utf8' });
360
+ const parser = sourceConfig.parser || 'lines';
361
+
362
+ let issues;
363
+ if (parser === 'json') {
364
+ const parsed = JSON.parse(output);
365
+ const items = Array.isArray(parsed) ? parsed : [];
366
+ issues = items.map((item, idx) => ({
367
+ id: `bash-${idx}`,
368
+ title: item.title || String(item),
369
+ severity: item.severity || 'info',
370
+ url: item.url || '',
371
+ age: '',
372
+ created_at: new Date().toISOString(),
373
+ meta: '',
374
+ source_type: 'bash',
375
+ issue_type: sourceConfig.issue_type || 'issue'
376
+ }));
377
+ } else {
378
+ // lines parser
379
+ const lines = output.split('\n').filter(l => l.trim() !== '');
380
+ issues = lines.map((line, idx) => ({
381
+ id: `bash-${idx}`,
382
+ title: line.trim(),
383
+ severity: 'info',
384
+ url: '',
385
+ age: '',
386
+ created_at: new Date().toISOString(),
387
+ meta: '',
388
+ source_type: 'bash',
389
+ issue_type: sourceConfig.issue_type || 'issue'
390
+ }));
391
+ }
392
+
393
+ return {
394
+ source_label: label,
395
+ source_type: 'bash',
396
+ status: 'ok',
397
+ issues
398
+ };
399
+ } catch (err) {
400
+ return {
401
+ source_label: label,
402
+ source_type: 'bash',
403
+ status: 'error',
404
+ error: `Bash command failed: ${err.message}`,
405
+ issues: []
406
+ };
407
+ }
408
+ }
409
+
410
+ // Production source handlers (v0.27-04)
411
+ const { handlePrometheus } = require('./observe-handler-prometheus.cjs');
412
+ const { handleGrafana } = require('./observe-handler-grafana.cjs');
413
+ const { handleLogstash } = require('./observe-handler-logstash.cjs');
414
+
415
+ // Internal work detection handler
416
+ const { handleInternal } = require('./observe-handler-internal.cjs');
417
+
418
+ module.exports = {
419
+ handleGitHub,
420
+ handleSentry,
421
+ handleSentryFeedback,
422
+ handleBash,
423
+ mapSentryIssuesToSchema,
424
+ mapSentryFeedbackToSchema,
425
+ // Production handlers (v0.27-04)
426
+ handlePrometheus,
427
+ handleGrafana,
428
+ handleLogstash,
429
+ // Internal work detection (quick-168)
430
+ handleInternal,
431
+ // Exported for testing
432
+ parseDuration,
433
+ formatAge,
434
+ classifySeverityFromLabels,
435
+ detectRepoFromGit
436
+ };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Source handler registry with pluggable dispatch
3
+ * Provides register/get/list/dispatch with timeout wrapping and Promise.allSettled parallel dispatch
4
+ */
5
+
6
+ // Internal handler map
7
+ const handlers = new Map();
8
+
9
+ /**
10
+ * Register a source handler function
11
+ * @param {string} sourceType - Source type identifier (e.g., "github", "sentry")
12
+ * @param {Function} handlerFn - Async handler: (sourceConfig, options) -> { source_label, source_type, status, issues[], error? }
13
+ * @throws {Error} If sourceType is already registered
14
+ */
15
+ function registerHandler(sourceType, handlerFn) {
16
+ if (handlers.has(sourceType)) {
17
+ throw new Error(`Handler already registered for source type: ${sourceType}`);
18
+ }
19
+ if (typeof handlerFn !== 'function') {
20
+ throw new Error(`Handler must be a function, got: ${typeof handlerFn}`);
21
+ }
22
+ handlers.set(sourceType, handlerFn);
23
+ }
24
+
25
+ /**
26
+ * Get the registered handler for a source type
27
+ * @param {string} sourceType - Source type identifier
28
+ * @returns {Function|null} Handler function or null if not registered
29
+ */
30
+ function getHandler(sourceType) {
31
+ return handlers.get(sourceType) || null;
32
+ }
33
+
34
+ /**
35
+ * List all registered source types
36
+ * @returns {string[]} Array of registered source type strings
37
+ */
38
+ function listHandlers() {
39
+ return Array.from(handlers.keys());
40
+ }
41
+
42
+ /**
43
+ * Dispatch a single source with timeout wrapping
44
+ * Looks up handler, wraps in Promise.race with timeout, returns standard schema on error
45
+ *
46
+ * @param {object} sourceConfig - Source configuration object with type, label, etc.
47
+ * @param {object} options - Options passed to handler (sinceOverride, limitOverride)
48
+ * @param {number} [timeoutSeconds=10] - Timeout in seconds
49
+ * @returns {Promise<object>} Standard schema result { source_label, source_type, status, issues[], error? }
50
+ */
51
+ async function dispatchSource(sourceConfig, options, timeoutSeconds) {
52
+ const timeout = timeoutSeconds ?? 10;
53
+ const label = sourceConfig.label || sourceConfig.type || 'unknown';
54
+ const type = sourceConfig.type || 'unknown';
55
+
56
+ const handlerFn = getHandler(type);
57
+ if (!handlerFn) {
58
+ return {
59
+ source_label: label,
60
+ source_type: type,
61
+ status: 'error',
62
+ error: `No handler registered for type: ${type}`,
63
+ issues: []
64
+ };
65
+ }
66
+
67
+ try {
68
+ const handlerPromise = handlerFn(sourceConfig, options || {});
69
+ const timeoutPromise = new Promise((_, reject) =>
70
+ setTimeout(() => reject(new Error(`Timeout after ${timeout}s`)), timeout * 1000)
71
+ );
72
+
73
+ const result = await Promise.race([handlerPromise, timeoutPromise]);
74
+ return result;
75
+ } catch (err) {
76
+ return {
77
+ source_label: label,
78
+ source_type: type,
79
+ status: 'error',
80
+ error: err.message || 'Unknown error',
81
+ issues: []
82
+ };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Dispatch all sources in parallel via Promise.allSettled
88
+ * One failing source does not block others (OBS-08)
89
+ *
90
+ * @param {object[]} sources - Array of source config objects
91
+ * @param {object} options - Options passed to each handler
92
+ * @returns {Promise<object[]>} Array of results (standard schema)
93
+ */
94
+ async function dispatchAll(sources, options) {
95
+ const promises = sources.map(source =>
96
+ dispatchSource(source, options, source.timeout)
97
+ );
98
+
99
+ const settled = await Promise.allSettled(promises);
100
+
101
+ return settled.map((result, idx) => {
102
+ if (result.status === 'fulfilled') {
103
+ return result.value;
104
+ }
105
+ // Rejected — should not happen since dispatchSource catches errors,
106
+ // but handle defensively
107
+ return {
108
+ source_label: sources[idx].label || sources[idx].type || 'unknown',
109
+ source_type: sources[idx].type || 'unknown',
110
+ status: 'error',
111
+ error: `Dispatch failed: ${result.reason?.message || 'Unknown error'}`,
112
+ issues: []
113
+ };
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Clear all registered handlers (for testing)
119
+ */
120
+ function clearHandlers() {
121
+ handlers.clear();
122
+ }
123
+
124
+ module.exports = {
125
+ registerHandler,
126
+ getHandler,
127
+ listHandlers,
128
+ dispatchSource,
129
+ dispatchAll,
130
+ clearHandlers
131
+ };