@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,121 @@
1
+ // ============================================================================
2
+ // Remediation item → GitHub issue bridge
3
+ //
4
+ // Converts a shaped remediation_item into a GitHub issue via `gh issue create`
5
+ // so the debt pipeline can feed into the existing dev-loop execution path.
6
+ // ============================================================================
7
+
8
+ import { execFileSync } from "node:child_process";
9
+ import { RemediationItemSchema } from "./debt-finding.mjs";
10
+
11
+ // ============================================================================
12
+ // Payload builder
13
+ // ============================================================================
14
+
15
+ /**
16
+ * Convert a remediation_item artifact into a GitHub-issue-compatible payload
17
+ * with structured body, acceptance criteria, and debt labels.
18
+ *
19
+ * @param {object} remediationItem — RemediationItemSchema-valid artifact
20
+ * @returns {{ title: string, body: string, labels: string[] }}
21
+ */
22
+ export function remediationToIssuePayload(remediationItem) {
23
+ const parsed = RemediationItemSchema.parse(remediationItem);
24
+ const acSection = parsed.acceptanceCriteria
25
+ .map((ac, i) => `${i + 1}. ${ac}`)
26
+ .join("\n");
27
+
28
+ const body = [
29
+ `## Remediation Item`,
30
+ ``,
31
+ `**Finding ID:** ${parsed.findingId}`,
32
+ `**Score:** ${parsed.score}`,
33
+ `**Primary file:** ${parsed.primaryFilePath || "N/A"}`,
34
+ ``,
35
+ `### Description`,
36
+ parsed.description,
37
+ ``,
38
+ `### Acceptance Criteria`,
39
+ acSection,
40
+ ``,
41
+ `### Source`,
42
+ `Generated by debt pipeline from ${parsed.signalIds.length} signal(s).`,
43
+ ].join("\n");
44
+
45
+ return {
46
+ title: parsed.title,
47
+ body,
48
+ labels: ["workflow"],
49
+ };
50
+ }
51
+
52
+ // ============================================================================
53
+ // Error message extraction
54
+ // ============================================================================
55
+
56
+ /**
57
+ * Coerce an error's stderr into a string.
58
+ * gh may write a Buffer or Uint8Array when execFileSync throws.
59
+ */
60
+ function errorMessage(err) {
61
+ if (typeof err.stderr === "string" && err.stderr.length > 0) {
62
+ return err.stderr.trim();
63
+ }
64
+ if (err.stderr && typeof err.stderr.toString === "function") {
65
+ const s = err.stderr.toString("utf-8").trim();
66
+ if (s.length > 0) return s;
67
+ }
68
+ if (err.message) return err.message;
69
+ return "gh issue create failed";
70
+ }
71
+
72
+ // ============================================================================
73
+ // GitHub issue creation
74
+ // ============================================================================
75
+
76
+ /**
77
+ * Create a GitHub issue from a remediation_item artifact.
78
+ *
79
+ * Calls `gh issue create` with the generated payload. The issue is assigned
80
+ * to @me via --assignee.
81
+ *
82
+ * @param {object} remediationItem — RemediationItemSchema-valid artifact
83
+ * @param {{ owner: string, name: string }} repo — parsed repository identifier
84
+ * @returns {{ ok: true, issueNumber: number, issueUrl: string } | { ok: false, error: string }}
85
+ */
86
+ export function createRemediationIssue(remediationItem, repo) {
87
+ const { title, body, labels } = remediationToIssuePayload(remediationItem);
88
+
89
+ try {
90
+ const args = [
91
+ "issue", "create",
92
+ "--title", title,
93
+ "--body", body,
94
+ "--assignee", "@me",
95
+ "--repo", `${repo.owner}/${repo.name}`,
96
+ ];
97
+
98
+ for (const label of labels) {
99
+ args.push("--label", label);
100
+ }
101
+
102
+ const result = execFileSync("gh", args, {
103
+ encoding: "utf-8",
104
+ stdio: ["pipe", "pipe", "pipe"],
105
+ }).trim();
106
+
107
+ // gh issue create output is typically the issue URL, e.g.
108
+ // https://github.com/owner/name/issues/123
109
+ const url = result;
110
+ const match = url.match(/\/issues\/(\d+)/);
111
+ const issueNumber = match ? parseInt(match[1], 10) : null;
112
+
113
+ if (!issueNumber) {
114
+ return { ok: false, error: `Could not parse issue number from gh output: ${url}` };
115
+ }
116
+
117
+ return { ok: true, issueNumber, issueUrl: url };
118
+ } catch (err) {
119
+ return { ok: false, error: errorMessage(err) };
120
+ }
121
+ }
@@ -0,0 +1,127 @@
1
+ // ============================================================================
2
+ // Debt signal cluster scoring model
3
+ //
4
+ // Transforms a cluster of debt_signal objects into a numeric 0-100 score.
5
+ //
6
+ // Three dimensions:
7
+ // - Frequency: how many signals in the cluster, capped and normalized
8
+ // - Severity: average severity hint value from signal source metadata
9
+ // - Impact: blast-radius / churn-risk heuristic based on signal properties
10
+ //
11
+ // Guarantees:
12
+ // - Deterministic: same inputs → same score
13
+ // - Monotonicity: higher frequency/severity/impact → higher or equal score
14
+ // - Boundary handling: zero inputs, single-signal clusters, max clusters
15
+ //
16
+ // Severity mapping (canonical):
17
+ // info=1, low=2, medium=3, high=4, critical=5
18
+ //
19
+ // Weights are hardcoded constants in this slice; not configurable.
20
+ // The formula is: score = frequencyScore * 0.35 + severityScore * 0.40 + impactScore * 0.25
21
+ // clamped to 0-100.
22
+ // ============================================================================
23
+
24
+ /**
25
+ * Map a DebtSignalSeverity string to a numeric weight 1-5.
26
+ * Returns 1 for unrecognized values (defensive).
27
+ *
28
+ * @param {string} severityHint
29
+ * @returns {number}
30
+ */
31
+ function severityWeight(severityHint) {
32
+ const map = { info: 1, low: 2, medium: 3, high: 4, critical: 5 };
33
+ return map[severityHint] ?? 1;
34
+ }
35
+
36
+ /**
37
+ * Compute the frequency score component (0-100).
38
+ * Uses a logarithmic cap so the first few signals matter most.
39
+ * 1 signal → 20, 3 signals → 58, 5 signals → 76, 10 signals → 100.
40
+ *
41
+ * @param {number} signalCount
42
+ * @returns {number}
43
+ */
44
+ function frequencyScore(signalCount) {
45
+ if (signalCount <= 0) return 0;
46
+ if (signalCount === 1) return 20;
47
+ // Logarithmic scaling: 20 + 80 * log2(count) / log2(10), capped at 100
48
+ const raw = 20 + 80 * (Math.log2(signalCount) / Math.log2(10));
49
+ return Math.min(100, Math.round(raw));
50
+ }
51
+
52
+ /**
53
+ * Compute the severity score component (0-100).
54
+ * Average severity weight mapped: 1→20, 3→60, 5→100.
55
+ *
56
+ * @param {Array<{ severityHint: string }>} signals
57
+ * @returns {number}
58
+ */
59
+ function severityScore(signals) {
60
+ if (signals.length === 0) return 0;
61
+ const total = signals.reduce((sum, s) => sum + severityWeight(s.severityHint), 0);
62
+ const avg = total / signals.length;
63
+ return Math.round(avg * 20); // 1..5 → 20..100
64
+ }
65
+
66
+ /**
67
+ * Compute the impact score component (0-100).
68
+ * Heuristic based on:
69
+ * - file presence: signals with known file paths are more actionable (+30 base if any)
70
+ * - confidence: average signal confidence boosts impact
71
+ * - signalKind diversity: more unique categories = broader blast radius
72
+ * Returns 0 for empty clusters.
73
+ *
74
+ * @param {Array<{ location?: { filePath?: string }, confidence?: number, signalKind?: string }>} signals
75
+ * @returns {number}
76
+ */
77
+ function impactScore(signals) {
78
+ if (signals.length === 0) return 0;
79
+
80
+ let score = 0;
81
+
82
+ // File presence: +30 if any signal has a file path
83
+ const hasFilePath = signals.some(s => s.location?.filePath);
84
+ if (hasFilePath) score += 30;
85
+
86
+ // Confidence boost: average confidence * 40
87
+ const confidences = signals.map(s => s.confidence ?? 1);
88
+ const avgConfidence = confidences.reduce((a, b) => a + b, 0) / confidences.length;
89
+ score += Math.round(avgConfidence * 40);
90
+
91
+ // Category diversity: unique signalKinds * 5, capped at 30
92
+ const uniqueKinds = new Set(signals.map(s => s.signalKind).filter(Boolean));
93
+ const diversityBonus = Math.min(30, uniqueKinds.size * 5);
94
+ score += diversityBonus;
95
+
96
+ return Math.min(100, score);
97
+ }
98
+
99
+ /**
100
+ * Weight constant — keeps module self-documenting and testable.
101
+ * @type {{ frequency: number, severity: number, impact: number }}
102
+ */
103
+ export const SCORE_WEIGHTS = Object.freeze({
104
+ frequency: 0.35,
105
+ severity: 0.40,
106
+ impact: 0.25,
107
+ });
108
+
109
+ /**
110
+ * Score a cluster of debt_signal objects.
111
+ *
112
+ * @param {Array<{ severityHint: string, location?: { filePath?: string }, confidence?: number, signalKind?: string }>} signals — array of debt_signal-compatible objects
113
+ * @returns {number} integer score 0-100
114
+ */
115
+ export function scoreCluster(signals) {
116
+ if (!Array.isArray(signals) || signals.length === 0) return 0;
117
+
118
+ const freq = frequencyScore(signals.length);
119
+ const sev = severityScore(signals);
120
+ const imp = impactScore(signals);
121
+
122
+ const raw = freq * SCORE_WEIGHTS.frequency +
123
+ sev * SCORE_WEIGHTS.severity +
124
+ imp * SCORE_WEIGHTS.impact;
125
+
126
+ return Math.min(100, Math.max(0, Math.round(raw)));
127
+ }
@@ -0,0 +1,214 @@
1
+ // ============================================================================
2
+ // Debt finding shaping rules
3
+ //
4
+ // Turns each scored debt_finding into exactly one outcome:
5
+ // - remediation_item: bounded, PR-sized fix with clear acceptance criteria
6
+ // - debt_epic: cross-cutting, needs decomposition before execution
7
+ // - defer: acknowledged, scheduled for future review
8
+ // - watch: low confidence, needs more signals before action
9
+ // - dismiss: false positive or already fixed
10
+ //
11
+ // Thresholds are hardcoded constants in this slice; not configurable.
12
+ // Exported for contract tests that verify predicate boundaries.
13
+ //
14
+ // Actionable outcomes (remediation_item, debt_epic) are gated on having
15
+ // at least one filePath. Theme-only clusters without file paths are
16
+ // downgraded to watch regardless of score.
17
+ // ============================================================================
18
+
19
+ // ============================================================================
20
+ // Threshold constants
21
+ // ============================================================================
22
+
23
+ /** Score >= ITEM_THRESHOLD → eligible for remediation_item or debt_epic */
24
+ export const ITEM_THRESHOLD = 65;
25
+
26
+ /** Score >= DEFER_THRESHOLD → defer */
27
+ export const DEFER_THRESHOLD = 50;
28
+
29
+ /** Score >= WATCH_THRESHOLD → watch */
30
+ export const WATCH_THRESHOLD = 30;
31
+
32
+ /** Below WATCH_THRESHOLD → dismiss */
33
+
34
+ /** Signals above this count → debt_epic (even with score >= ITEM_THRESHOLD) */
35
+ export const EPIC_SIGNAL_COUNT_THRESHOLD = 3;
36
+
37
+ // ============================================================================
38
+ // Shape outcome
39
+ // ============================================================================
40
+
41
+ /** @typedef {"remediation_item"|"debt_epic"|"defer"|"watch"|"dismiss"} ShapeOutcome */
42
+
43
+ // ============================================================================
44
+ // Helpers
45
+ // ============================================================================
46
+
47
+ /**
48
+ * Deterministic epoch constant for timestamp fallback.
49
+ * "1970-01-01T00:00:00.000Z"
50
+ */
51
+ const EPOCH_TS = "1970-01-01T00:00:00.000Z";
52
+
53
+ /**
54
+ * Pass through finding timestamps. Falls back to epoch when finding
55
+ * timestamps are missing — never uses wall-clock time.
56
+ *
57
+ * @param {object} finding — enriched debt_finding with createdAt/updatedAt
58
+ * @returns {{ createdAt: string, updatedAt: string }}
59
+ */
60
+ function deriveTimestamps(finding) {
61
+ return {
62
+ createdAt: finding.createdAt || EPOCH_TS,
63
+ updatedAt: finding.updatedAt || finding.createdAt || EPOCH_TS,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Check whether a finding has actionable file paths.
69
+ *
70
+ * @param {object} finding — enriched debt_finding
71
+ * @returns {boolean}
72
+ */
73
+ function hasFilePaths(finding) {
74
+ const paths = finding.locationSummary?.filePaths;
75
+ return Array.isArray(paths) && paths.length > 0;
76
+ }
77
+
78
+ // ============================================================================
79
+ // Pure predicate: classify a finding into a shape outcome
80
+ // ============================================================================
81
+
82
+ /**
83
+ * Determine the shape outcome for a debt_finding.
84
+ * Actionable outcomes (remediation_item, debt_epic) require at least
85
+ * one filePath; theme-only clusters without file paths are downgraded
86
+ * to watch.
87
+ *
88
+ * @param {object} finding — debt_finding shape with at least { score, _signalCount, signalIds }
89
+ * @returns {ShapeOutcome}
90
+ */
91
+ function classifyShape(finding) {
92
+ const score = finding.score ?? 0;
93
+ const signalCount = finding._signalCount ?? (finding.signalIds?.length ?? 1);
94
+
95
+ if (score >= ITEM_THRESHOLD && hasFilePaths(finding)) {
96
+ return signalCount > EPIC_SIGNAL_COUNT_THRESHOLD ? "debt_epic" : "remediation_item";
97
+ }
98
+ if (score >= ITEM_THRESHOLD) {
99
+ // High score but no file paths — downgrade to watch
100
+ return "watch";
101
+ }
102
+ if (score >= DEFER_THRESHOLD) return "defer";
103
+ if (score >= WATCH_THRESHOLD) return "watch";
104
+ return "dismiss";
105
+ }
106
+
107
+ // ============================================================================
108
+ // Artifact builders
109
+ // ============================================================================
110
+
111
+ /**
112
+ * Build a remediation_item artifact from a finding.
113
+ * Uses structured _categories from the cluster.
114
+ *
115
+ * @param {object} finding — enriched debt_finding with _categories array
116
+ * @returns {object} RemediationItemSchema-compatible shape
117
+ */
118
+ function buildRemediationItem(finding) {
119
+ const cats = (finding._categories && finding._categories.length > 0)
120
+ ? finding._categories.join(", ")
121
+ : "unknown";
122
+ const { createdAt, updatedAt } = deriveTimestamps(finding);
123
+ return {
124
+ kind: "remediation_item",
125
+ findingId: finding.id,
126
+ title: finding.title,
127
+ description: finding.description || `Remediation for finding ${finding.id}`,
128
+ acceptanceCriteria: [
129
+ `Address ${cats} issues in affected files`,
130
+ `Verify no regression in existing test suite`,
131
+ `Maintain >= 90% coverage`,
132
+ ],
133
+ score: finding.score ?? 0,
134
+ primaryFilePath: finding.locationSummary?.primaryFilePath,
135
+ filePaths: finding.locationSummary?.filePaths,
136
+ signalIds: finding.signalIds,
137
+ sourceType: "debt_pipeline",
138
+ createdAt,
139
+ updatedAt,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Build a debt_epic artifact from a finding.
145
+ *
146
+ * @param {object} finding — enriched debt_finding
147
+ * @returns {object} DebtEpicSchema-compatible shape
148
+ */
149
+ function buildDebtEpic(finding) {
150
+ const { createdAt, updatedAt } = deriveTimestamps(finding);
151
+ return {
152
+ kind: "debt_epic",
153
+ findingId: finding.id,
154
+ title: finding.title,
155
+ description: finding.description || `Epic for finding ${finding.id}`,
156
+ score: finding.score ?? 0,
157
+ filePaths: finding.locationSummary?.filePaths,
158
+ signalIds: finding.signalIds,
159
+ estimatedItems: Math.ceil((finding._signalCount ?? 1) / 2),
160
+ createdAt,
161
+ updatedAt,
162
+ };
163
+ }
164
+
165
+ // ============================================================================
166
+ // Public API
167
+ // ============================================================================
168
+
169
+ /**
170
+ * Shape a single enriched finding into its outcome.
171
+ *
172
+ * Returns an object with { outcome, artifact } where artifact is defined
173
+ * for remediation_item and debt_epic outcomes, and null for defer/watch/dismiss.
174
+ *
175
+ * @param {object} finding — enriched debt_finding (with _signalCount)
176
+ * @returns {{ outcome: ShapeOutcome, artifact: object|null }}
177
+ */
178
+ export function shapeFinding(finding) {
179
+ const outcome = classifyShape(finding);
180
+
181
+ let artifact = null;
182
+ if (outcome === "remediation_item") {
183
+ artifact = buildRemediationItem(finding);
184
+ } else if (outcome === "debt_epic") {
185
+ artifact = buildDebtEpic(finding);
186
+ }
187
+
188
+ return { outcome, artifact };
189
+ }
190
+
191
+ /**
192
+ * Shape an array of enriched findings, returning the outcome + artifact for each.
193
+ *
194
+ * @param {Array<object>} findings — enriched debt_finding array
195
+ * @returns {Array<{ outcome: ShapeOutcome, artifact: object|null, findingId: string }>}
196
+ */
197
+ export function shapeFindings(findings) {
198
+ return findings.map(f => {
199
+ const { outcome, artifact } = shapeFinding(f);
200
+ return { outcome, artifact, findingId: f.id };
201
+ });
202
+ }
203
+
204
+ /**
205
+ * Run the full pipeline: cluster → score → shape, return shaped artifacts.
206
+ *
207
+ * @param {Array<object>} signals — debt_signal-compatible array
208
+ * @returns {Array<{ outcome: ShapeOutcome, artifact: object|null, findingId: string }>}
209
+ */
210
+ export async function runPipeline(signals) {
211
+ const { clusterSignalsEnriched } = await import("./cluster.mjs");
212
+ const findings = clusterSignalsEnriched(signals);
213
+ return shapeFindings(findings);
214
+ }