@dev-loops/core 0.1.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 (54) hide show
  1. package/bin/capture-deep-persona-signals.mjs +143 -0
  2. package/bin/ensure-phase-files.mjs +7 -0
  3. package/bin/log-bash-exit-1.mjs +7 -0
  4. package/bin/parse-review-threads.mjs +7 -0
  5. package/package.json +78 -0
  6. package/src/analysis/change-classifier.mjs +146 -0
  7. package/src/analysis/diff-analyzer.mjs +285 -0
  8. package/src/bash-exit-one.mjs +130 -0
  9. package/src/cli/helpers.mjs +22 -0
  10. package/src/cli/primitives.mjs +70 -0
  11. package/src/cli/retry-wrapper.mjs +169 -0
  12. package/src/cli/subcommand-runner.mjs +246 -0
  13. package/src/config/config.mjs +965 -0
  14. package/src/debt/cluster.mjs +240 -0
  15. package/src/debt/debt-finding.mjs +68 -0
  16. package/src/debt/debt-signal.mjs +46 -0
  17. package/src/debt/deep-persona-signals.mjs +266 -0
  18. package/src/debt/remediation-to-issue.mjs +121 -0
  19. package/src/debt/score.mjs +127 -0
  20. package/src/debt/shape.mjs +214 -0
  21. package/src/github/copilot-helpers.mjs +343 -0
  22. package/src/github/repo-slug.mjs +105 -0
  23. package/src/github/review-threads.mjs +343 -0
  24. package/src/harness/adapter.mjs +57 -0
  25. package/src/harness/index.mjs +3 -0
  26. package/src/harness/noop-adapter.mjs +22 -0
  27. package/src/harness/pi-adapter.mjs +47 -0
  28. package/src/loop/async-start-contract.mjs +170 -0
  29. package/src/loop/conductor-routing.mjs +817 -0
  30. package/src/loop/copilot-ci-status.mjs +255 -0
  31. package/src/loop/copilot-loop-iterations.mjs +161 -0
  32. package/src/loop/copilot-loop-state.mjs +510 -0
  33. package/src/loop/handoff-envelope.mjs +800 -0
  34. package/src/loop/issue-refinement-artifact.mjs +268 -0
  35. package/src/loop/lifecycle-state.mjs +342 -0
  36. package/src/loop/phase-files.mjs +187 -0
  37. package/src/loop/policy-constants.mjs +17 -0
  38. package/src/loop/pr-gate-coordination.mjs +1278 -0
  39. package/src/loop/public-dev-loop-routing-contract.mjs +277 -0
  40. package/src/loop/public-dev-loop-routing.mjs +1746 -0
  41. package/src/loop/queue-board-ordering.mjs +38 -0
  42. package/src/loop/queue-board-sync.mjs +223 -0
  43. package/src/loop/queue-driver.mjs +164 -0
  44. package/src/loop/queue-parallel.mjs +190 -0
  45. package/src/loop/queue-state.mjs +230 -0
  46. package/src/loop/retrospective-checkpoint.mjs +178 -0
  47. package/src/loop/reviewer-loop-state.mjs +456 -0
  48. package/src/loop/run-inspection.mjs +604 -0
  49. package/src/loop/steering.mjs +793 -0
  50. package/src/loop/timeout-policy.mjs +73 -0
  51. package/src/loop/tracker-first-loop-state.mjs +87 -0
  52. package/src/loop/tracker-pr-state.mjs +301 -0
  53. package/src/loop/worktree-guard.mjs +141 -0
  54. package/src/refinement/ac-dod-matrix.mjs +95 -0
@@ -0,0 +1,240 @@
1
+ // ============================================================================
2
+ // Debt signal clustering logic
3
+ //
4
+ // Groups debt_signal objects into debt_finding clusters.
5
+ //
6
+ // Clustering rules:
7
+ // - file: signals share the same location.filePath
8
+ // - module: signals share the same parent directory of filePath
9
+ // - theme: signals share the same signalKind category
10
+ // - Precedence: file > module > theme
11
+ // - A signal belongs to exactly one cluster.
12
+ // - Signals with no file, module, or theme form singleton clusters.
13
+ //
14
+ // Finding ID determinism:
15
+ // Finding IDs are derived from sorted signalIds + clusterReason using a
16
+ // stable hash, so re-running clustering on the same input produces the
17
+ // same debt_finding artifacts.
18
+ //
19
+ // Output: array of debt_finding objects with embedded signal references,
20
+ // signalIds, and aggregated score.
21
+ // ============================================================================
22
+
23
+ import { createHash } from "node:crypto";
24
+ import { scoreCluster } from "./score.mjs";
25
+
26
+ /**
27
+ * Derive a deterministic finding ID from sorted signal IDs and the cluster reason.
28
+ * Uses SHA-256 truncated to a UUID-v4-format string for stable cross-run identity.
29
+ *
30
+ * @param {Array<string>} signalIds — sorted array of signal UUIDs
31
+ * @param {string} clusterReason — "file", "module", "theme", or "singleton"
32
+ * @returns {string} UUID-v4-format deterministic ID
33
+ */
34
+ function deriveFindingId(signalIds, clusterReason) {
35
+ const sorted = [...signalIds].sort();
36
+ const input = sorted.join(":") + "|" + clusterReason;
37
+ const hash = createHash("sha256").update(input).digest("hex");
38
+ // Format as UUID v4: 8-4-4-4-12 using first 32 hex chars
39
+ const variantNibble = (parseInt(hash[16], 16) & 0x3 | 0x8).toString(16);
40
+ return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-${variantNibble}${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
41
+ }
42
+
43
+ /**
44
+ * Derive deterministic timestamps from signal timestamps.
45
+ * Uses the earliest signal timestamp for createdAt and the latest for updatedAt.
46
+ *
47
+ * @param {Array<object>} signals
48
+ * @returns {{ createdAt: string, updatedAt: string }}
49
+ */
50
+ function deriveTimestamps(signals) {
51
+ const timestamps = signals.map(s => s.timestamp).filter(Boolean).sort();
52
+ if (timestamps.length === 0) {
53
+ const epoch = "1970-01-01T00:00:00.000Z";
54
+ return { createdAt: epoch, updatedAt: epoch };
55
+ }
56
+ return { createdAt: timestamps[0], updatedAt: timestamps[timestamps.length - 1] };
57
+ }
58
+
59
+ /**
60
+ * Extract the file key from a signal: location.filePath, or undefined.
61
+ * @param {object} signal
62
+ * @returns {string|undefined}
63
+ */
64
+ function fileKey(signal) {
65
+ return signal.location?.filePath || undefined;
66
+ }
67
+
68
+ /**
69
+ * Extract the module key from a signal:
70
+ * parent directory of filePath (dirname).
71
+ * Returns undefined when no filePath exists — module clustering is
72
+ * strictly directory-based and does not fall back to signalKind.
73
+ * Theme clustering handles signalKind grouping separately.
74
+ *
75
+ * @param {object} signal
76
+ * @returns {string|undefined}
77
+ */
78
+ function moduleKey(signal) {
79
+ const fp = signal.location?.filePath;
80
+ if (!fp) return undefined;
81
+ const lastSlash = fp.lastIndexOf("/");
82
+ if (lastSlash >= 0) return fp.slice(0, lastSlash);
83
+ return fp; // no directory separator — treat as its own module
84
+ }
85
+
86
+ /**
87
+ * Extract the theme key from a signal: signalKind category.
88
+ * @param {object} signal
89
+ * @returns {string|undefined}
90
+ */
91
+ function themeKey(signal) {
92
+ return signal.signalKind || undefined;
93
+ }
94
+
95
+ /**
96
+ * Group signals by a key extraction function.
97
+ * Returns Map<key, signal[]> where key may be undefined for signals lacking that dimension.
98
+ *
99
+ * @param {Array<object>} signals
100
+ * @param {(signal: object) => string|undefined} keyFn
101
+ * @returns {Map<string|undefined, Array<object>>}
102
+ */
103
+ function groupByKey(signals, keyFn) {
104
+ const map = new Map();
105
+ for (const signal of signals) {
106
+ const key = keyFn(signal);
107
+ if (!map.has(key)) map.set(key, []);
108
+ map.get(key).push(signal);
109
+ }
110
+ return map;
111
+ }
112
+
113
+ /**
114
+ * Create a debt_finding from a cluster of signals.
115
+ *
116
+ * @param {Array<object>} signals — debt_signal-compatible objects
117
+ * @param {string} clusterReason — "file", "module", "theme", or "singleton"
118
+ * @returns {object} enriched debt_finding shape with internal fields
119
+ */
120
+ function buildFinding(signals, clusterReason) {
121
+ const score = scoreCluster(signals);
122
+
123
+ // Build title from cluster reason + primary category
124
+ const categories = [...new Set(signals.map(s => s.signalKind).filter(Boolean))].sort();
125
+ const primaryCategory = categories[0] || "unknown";
126
+
127
+ // Build location summary
128
+ const filePaths = [...new Set(
129
+ signals.map(s => s.location?.filePath).filter(Boolean)
130
+ )].sort();
131
+
132
+ const title = filePaths.length === 1
133
+ ? `${clusterReason}: ${primaryCategory} in ${filePaths[0]}`
134
+ : `${clusterReason}: ${primaryCategory} (${signals.length} signals)`;
135
+
136
+ const description = `Clustered by ${clusterReason}. ` +
137
+ `Categories: ${categories.join(", ")}. ` +
138
+ `Files: ${filePaths.length > 0 ? filePaths.join(", ") : "none"}.`;
139
+
140
+ const sortedSignalIds = signals.map(s => s.id).sort();
141
+ const id = deriveFindingId(sortedSignalIds, clusterReason);
142
+ const { createdAt, updatedAt } = deriveTimestamps(signals);
143
+
144
+ return {
145
+ id,
146
+ signalIds: sortedSignalIds,
147
+ validationStatus: "pending",
148
+ score,
149
+ remediationShape: "watch_only", // placeholder; shaped later
150
+ title: title.slice(0, 200),
151
+ description: description.slice(0, 500),
152
+ locationSummary: filePaths.length > 0
153
+ ? { filePaths, primaryFilePath: filePaths[0] }
154
+ : undefined,
155
+ createdAt,
156
+ updatedAt,
157
+ // Internal fields for shaping (not in the DebtFindingSchema output)
158
+ _clusterReason: clusterReason,
159
+ _signalCount: signals.length,
160
+ _categories: categories,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Cluster debt_signal objects into debt_finding arrays.
166
+ *
167
+ * Multi-pass algorithm:
168
+ * 1. Group by file (location.filePath exact match)
169
+ * 2. From remaining, group by module (directory of filePath)
170
+ * 3. From remaining, group by theme (signalKind)
171
+ * 4. Remaining → singleton clusters
172
+ *
173
+ * Each signal appears in exactly one cluster.
174
+ * Clusters with only one signal when grouped by a dimension
175
+ * are deferred to the next pass.
176
+ *
177
+ * @param {Array<object>} signals — array of debt_signal-compatible objects
178
+ * @returns {Array<object>} array of clean debt_finding shapes (no internal fields)
179
+ */
180
+ export function clusterSignals(signals) {
181
+ const enriched = clusterSignalsEnriched(signals);
182
+ return enriched.map(({ _clusterReason, _signalCount, _categories, ...clean }) => clean);
183
+ }
184
+
185
+ /**
186
+ * Cluster signals and return findings enriched with internal metadata
187
+ * for shaping (_clusterReason, _signalCount, _categories).
188
+ *
189
+ * @param {Array<object>} signals — array of debt_signal-compatible objects
190
+ * @returns {Array<object>} array of enriched debt_finding shapes
191
+ */
192
+ export function clusterSignalsEnriched(signals) {
193
+ if (!Array.isArray(signals)) return [];
194
+ if (signals.length === 0) return [];
195
+
196
+ const findings = [];
197
+ let remaining = [...signals];
198
+
199
+ // Pass 1: file
200
+ const fileGroups = groupByKey(remaining, fileKey);
201
+ remaining = [];
202
+ for (const [key, group] of fileGroups) {
203
+ if (key === undefined || group.length <= 1) {
204
+ remaining.push(...group);
205
+ } else {
206
+ findings.push(buildFinding(group, "file"));
207
+ }
208
+ }
209
+
210
+ // Pass 2: module (directory-based only; no signalKind fallback)
211
+ const moduleGroups = groupByKey(remaining, moduleKey);
212
+ remaining = [];
213
+ for (const [key, group] of moduleGroups) {
214
+ if (key === undefined || group.length <= 1) {
215
+ remaining.push(...group);
216
+ } else {
217
+ findings.push(buildFinding(group, "module"));
218
+ }
219
+ }
220
+
221
+ // Pass 3: theme
222
+ const themeGroups = groupByKey(remaining, themeKey);
223
+ remaining = [];
224
+ for (const [key, group] of themeGroups) {
225
+ if (key === undefined || group.length <= 1) {
226
+ remaining.push(...group);
227
+ } else {
228
+ findings.push(buildFinding(group, "theme"));
229
+ }
230
+ }
231
+
232
+ // Pass 4: singletons for any remaining
233
+ for (const signal of remaining) {
234
+ findings.push(buildFinding([signal], "singleton"));
235
+ }
236
+
237
+ // Stable output order: sort findings by id
238
+ findings.sort((a, b) => a.id.localeCompare(b.id));
239
+ return findings;
240
+ }
@@ -0,0 +1,68 @@
1
+ import { z } from "zod";
2
+
3
+ export const DebtFindingValidationStatus = z.enum([
4
+ "pending",
5
+ "validated",
6
+ "rejected",
7
+ "stale",
8
+ ]);
9
+
10
+ export const DebtFindingRemediationShape = z.enum([
11
+ "item",
12
+ "epic",
13
+ "defer",
14
+ "watch_only",
15
+ "dismissed",
16
+ ]);
17
+
18
+ export const DebtFindingLocationSummary = z.strictObject({
19
+ filePaths: z.array(z.string().min(1)).optional(),
20
+ primaryFilePath: z.string().min(1).optional(),
21
+ });
22
+
23
+ export const DebtFindingSchema = z.strictObject({
24
+ id: z.string().uuid(),
25
+ signalIds: z.array(z.string().uuid()).min(1),
26
+ validationStatus: DebtFindingValidationStatus,
27
+ score: z.number().min(0).max(100).optional(),
28
+ remediationShape: DebtFindingRemediationShape,
29
+ title: z.string().min(1).max(200),
30
+ description: z.string().min(1).optional(),
31
+ locationSummary: DebtFindingLocationSummary.optional(),
32
+ createdAt: z.string().datetime(),
33
+ updatedAt: z.string().datetime(),
34
+ });
35
+
36
+ // ============================================================================
37
+ // Remediation item schema — a bounded, PR-sized fix ready for the execution loop
38
+ // ============================================================================
39
+ export const RemediationItemSchema = z.strictObject({
40
+ kind: z.literal("remediation_item"),
41
+ findingId: z.string().uuid(),
42
+ title: z.string().min(1).max(200),
43
+ description: z.string().min(1),
44
+ acceptanceCriteria: z.array(z.string().min(1)).min(1),
45
+ score: z.number().min(0).max(100),
46
+ primaryFilePath: z.string().min(1).optional(),
47
+ filePaths: z.array(z.string().min(1)).min(1),
48
+ signalIds: z.array(z.string().uuid()).min(1),
49
+ sourceType: z.string().min(1).optional(),
50
+ createdAt: z.string().datetime(),
51
+ updatedAt: z.string().datetime(),
52
+ });
53
+
54
+ // ============================================================================
55
+ // Debt epic schema — cross-cutting work that needs decomposition before execution
56
+ // ============================================================================
57
+ export const DebtEpicSchema = z.strictObject({
58
+ kind: z.literal("debt_epic"),
59
+ findingId: z.string().uuid(),
60
+ title: z.string().min(1).max(200),
61
+ description: z.string().min(1),
62
+ score: z.number().min(0).max(100),
63
+ filePaths: z.array(z.string().min(1)).min(1),
64
+ signalIds: z.array(z.string().uuid()).min(1),
65
+ estimatedItems: z.number().int().positive(),
66
+ createdAt: z.string().datetime(),
67
+ updatedAt: z.string().datetime(),
68
+ });
@@ -0,0 +1,46 @@
1
+ import { z } from "zod";
2
+
3
+ export const DebtSignalSourceType = z.enum([
4
+ "pr_review_deep_persona",
5
+ "repo_audit",
6
+ "flaky_test",
7
+ "ci_failure",
8
+ "manual_review",
9
+ "dependency_alert",
10
+ "review_churn",
11
+ "incident_followup",
12
+ "workflow_pain",
13
+ ]);
14
+
15
+ export const DebtSignalSeverity = z.enum([
16
+ "info",
17
+ "low",
18
+ "medium",
19
+ "high",
20
+ "critical",
21
+ ]);
22
+
23
+ export const DebtSignalLocation = z.strictObject({
24
+ filePath: z.string().min(1).optional(),
25
+ lineStart: z.number().int().positive().optional(),
26
+ lineEnd: z.number().int().positive().optional(),
27
+ commitSha: z.string().regex(/^[a-f0-9]{7,40}$/).optional(),
28
+ url: z.string().url().optional(),
29
+ });
30
+
31
+ export const DebtSignalRepository = z.strictObject({
32
+ owner: z.string().min(1),
33
+ name: z.string().min(1),
34
+ });
35
+
36
+ export const DebtSignalSchema = z.strictObject({
37
+ id: z.string().uuid(),
38
+ sourceType: DebtSignalSourceType,
39
+ signalKind: z.string().min(1).max(100),
40
+ location: DebtSignalLocation,
41
+ severityHint: DebtSignalSeverity,
42
+ timestamp: z.string().datetime(),
43
+ rawPayload: z.record(z.string(), z.unknown()).optional(),
44
+ repository: DebtSignalRepository.optional(),
45
+ confidence: z.number().min(0).max(1).default(1),
46
+ });
@@ -0,0 +1,266 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { DebtSignalSchema } from "./debt-signal.mjs";
3
+ import { loadDevLoopConfig } from "../config/config.mjs";
4
+
5
+ // ============================================================================
6
+ // Flag phrase inventory — derived from personas.deep prompt in defaults.yaml
7
+ //
8
+ // Confidence values use the canonical DebtSignalSchema 0..1 range (0.9 = 90%).
9
+ // This matches the schema default: z.number().min(0).max(1).default(1).
10
+ // ============================================================================
11
+
12
+ /** @type {Array<{ phrase: RegExp, category: string, severity: string, confidence: number }>} */
13
+ const FLAG_PATTERNS = [
14
+ {
15
+ phrase: /(?:crossing|crossed|exceeds?)\s+(?:1000|1,000)\+?\s+lines/i,
16
+ category: "file_size",
17
+ severity: "high",
18
+ confidence: 0.9,
19
+ },
20
+ {
21
+ phrase: /conditionals?\s+bolted\s+onto\s+unrelated\s+paths/i,
22
+ category: "spaghetti_branching",
23
+ severity: "high",
24
+ confidence: 0.9,
25
+ },
26
+ {
27
+ phrase: /\bspaghetti\b/i,
28
+ category: "spaghetti_branching",
29
+ severity: "high",
30
+ confidence: 0.9,
31
+ },
32
+ {
33
+ phrase: /thin\s+wrapper/i,
34
+ category: "thin_wrapper",
35
+ severity: "medium",
36
+ confidence: 0.9,
37
+ },
38
+ {
39
+ phrase: /re-export\s*[- ]?only/i,
40
+ category: "thin_wrapper",
41
+ severity: "medium",
42
+ confidence: 0.9,
43
+ },
44
+ {
45
+ phrase: /identity\s+abstraction/i,
46
+ category: "thin_wrapper",
47
+ severity: "medium",
48
+ confidence: 0.9,
49
+ },
50
+ {
51
+ phrase: /feature\s+logic\s+leaking\s+into/i,
52
+ category: "leaky_feature_logic",
53
+ severity: "high",
54
+ confidence: 0.9,
55
+ },
56
+ {
57
+ phrase: /leaking\s+into\s+shared/i,
58
+ category: "leaky_feature_logic",
59
+ severity: "high",
60
+ confidence: 0.9,
61
+ },
62
+ {
63
+ phrase: /cast[- ]?heavy/i,
64
+ category: "weak_contract",
65
+ severity: "medium",
66
+ confidence: 0.9,
67
+ },
68
+ {
69
+ phrase: /optionality[- ]?heavy/i,
70
+ category: "weak_contract",
71
+ severity: "medium",
72
+ confidence: 0.9,
73
+ },
74
+ {
75
+ phrase: /any[- ]?typed\s+contract/i,
76
+ category: "weak_contract",
77
+ severity: "medium",
78
+ confidence: 0.9,
79
+ },
80
+ {
81
+ phrase: /code\s+judo/i,
82
+ category: "simplification_opportunity",
83
+ severity: "medium",
84
+ confidence: 0.9,
85
+ },
86
+ {
87
+ phrase: /prefer\s+deletion\s+over\s+addition/i,
88
+ category: "simplification_opportunity",
89
+ severity: "medium",
90
+ confidence: 0.9,
91
+ },
92
+ ];
93
+
94
+ const FILE_PATH_RE = /[\w/\-.]+\.(?:m?js|ts|tsx|jsx|mjs)/i;
95
+
96
+ // ============================================================================
97
+ // Helpers
98
+ // ============================================================================
99
+
100
+ /**
101
+ * Match a comment body against known deep-persona flag phrases.
102
+ * Returns the match when a specific pattern matches, or null when no
103
+ * patterns match the body.
104
+ *
105
+ * @param {string} body
106
+ * @returns {{ category: string, severity: string, confidence: number, matchedPhrase: string|null }|null}
107
+ */
108
+ function matchDeepPersonaFlags(body) {
109
+ for (const pattern of FLAG_PATTERNS) {
110
+ if (pattern.phrase.test(body)) {
111
+ return {
112
+ category: pattern.category,
113
+ severity: pattern.severity,
114
+ confidence: pattern.confidence,
115
+ matchedPhrase: pattern.phrase.source,
116
+ };
117
+ }
118
+ }
119
+
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * Extract a file path from a comment body, or return an empty string if none found.
125
+ *
126
+ * @param {string} body
127
+ * @returns {string}
128
+ */
129
+ function extractFilePath(body) {
130
+ const match = body.match(FILE_PATH_RE);
131
+ return match ? match[0] : "";
132
+ }
133
+
134
+ /**
135
+ * Build a debt_signal object from a matched deep-persona comment.
136
+ *
137
+ * @param {object} comment - Normalized comment from parseReviewThreads output
138
+ * @param {string} category - Inferred category
139
+ * @param {string} severity - Severity hint
140
+ * @param {number} confidence - Confidence score (0..1)
141
+ * @param {string|null} matchedPhrase - The regex source that matched
142
+ * @param {{ prNumber: string|number, prUrl: string }} prMeta
143
+ * @returns {object}
144
+ */
145
+ function buildDebtSignal(comment, category, severity, confidence, matchedPhrase, prMeta) {
146
+ const filePath = extractFilePath(comment.body);
147
+
148
+ return {
149
+ id: randomUUID(),
150
+ sourceType: "pr_review_deep_persona",
151
+ signalKind: category,
152
+ location: filePath ? { filePath } : {},
153
+ severityHint: severity,
154
+ timestamp: new Date().toISOString(),
155
+ confidence,
156
+ rawPayload: {
157
+ description: comment.body,
158
+ metadata: {
159
+ prNumber: String(prMeta.prNumber),
160
+ prUrl: prMeta.prUrl,
161
+ commentId: comment.id,
162
+ threadId: comment.threadId,
163
+ isResolved: comment.isResolved ?? false,
164
+ category,
165
+ matchedPhrase,
166
+ },
167
+ },
168
+ };
169
+ }
170
+
171
+ // ============================================================================
172
+ // Public API
173
+ // ============================================================================
174
+
175
+ /**
176
+ * Extract deep-persona debt_signal artifacts from normalized review-thread JSON.
177
+ *
178
+ * Accepts the output of `parseReviewThreads()` (the `comments` array with
179
+ * normalized `{ id, threadId, author, body, isActionable }` entries).
180
+ *
181
+ * Filters to only bot-authored comments that match known deep-persona flag
182
+ * phrases. Bots are identified by `author.isBot === true` or `author.type === "Bot"`.
183
+ *
184
+ * @param {{ comments: Array<{ id: string, threadId: string, author: { login: string, type: string, isBot: boolean }, body: string, isActionable?: boolean, isResolved?: boolean }>, threads?: Array<{ id: string, isResolved: boolean }> }} parsedOutput - parseReviewThreads() output
185
+ * @param {{ prNumber: string|number, prUrl: string }} prMeta
186
+ * @returns {Array<object>} Array of debt_signal objects compatible with DebtSignalSchema
187
+ */
188
+ export function extractDeepPersonaSignals(parsedOutput, prMeta) {
189
+ if (!parsedOutput || !Array.isArray(parsedOutput.comments)) {
190
+ throw new Error("Invalid parsed output: expected { comments: [...] } from parseReviewThreads()");
191
+ }
192
+
193
+ // Build a thread-id → isResolved map for fast lookup
194
+ const threadResolved = new Map();
195
+ if (Array.isArray(parsedOutput.threads)) {
196
+ for (const thread of parsedOutput.threads) {
197
+ threadResolved.set(thread.id, Boolean(thread.isResolved));
198
+ }
199
+ }
200
+
201
+ const signals = [];
202
+
203
+ for (const comment of parsedOutput.comments) {
204
+ // Only process bot-authored comments (all Copilot personas emit as bots)
205
+ if (!comment.author || (!comment.author.isBot && comment.author.type !== "Bot")) {
206
+ continue;
207
+ }
208
+
209
+ if (!comment.body || comment.body.trim().length === 0) {
210
+ continue;
211
+ }
212
+
213
+ const match = matchDeepPersonaFlags(comment.body);
214
+ if (!match) {
215
+ continue;
216
+ }
217
+
218
+ // Enrich comment with isResolved from thread data
219
+ const isResolved = threadResolved.has(comment.threadId)
220
+ ? threadResolved.get(comment.threadId)
221
+ : (comment.isResolved ?? false);
222
+
223
+ const signal = buildDebtSignal(
224
+ { ...comment, isResolved },
225
+ match.category,
226
+ match.severity,
227
+ match.confidence,
228
+ match.matchedPhrase,
229
+ prMeta,
230
+ );
231
+
232
+ // Validate against canonical schema — throw on regression
233
+ signals.push(DebtSignalSchema.parse(signal));
234
+ }
235
+
236
+ return signals;
237
+ }
238
+
239
+ /**
240
+ * Return the known deep-persona flag phrase regex sources for inspection.
241
+ *
242
+ * @returns {Array<string>}
243
+ */
244
+ export function getDeepPersonaFlagPhrases() {
245
+ return FLAG_PATTERNS.map((p) => p.phrase.source);
246
+ }
247
+
248
+ /**
249
+ * Verify that all known deep-persona flag phrase regex patterns match
250
+ * the loaded deep persona prompt text. Returns an array of regex sources
251
+ * whose patterns did not find any match in the prompt (empty = all match).
252
+ *
253
+ * @returns {Promise<Array<string>>}
254
+ */
255
+ export async function verifyPromptStability() {
256
+ const { config, errors } = await loadDevLoopConfig();
257
+ if (errors.length > 0) {
258
+ throw new Error("Cannot verify prompt stability: config load errors: " +
259
+ errors.map(e => e.message).join("; "));
260
+ }
261
+ const deepPrompt = config?.personas?.deep?.prompt ?? "";
262
+
263
+ return FLAG_PATTERNS
264
+ .filter((p) => !p.phrase.test(deepPrompt))
265
+ .map((p) => p.phrase.source);
266
+ }