@dev-loops/core 0.6.0 → 0.7.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.
- package/package.json +3 -7
- package/src/analysis/change-classifier.mjs +50 -6
- package/src/analysis/diff-analyzer.mjs +68 -12
- package/src/claude/hook-decisions.mjs +138 -15
- package/src/config/config.mjs +167 -97
- package/src/config/extension-defaults.yaml +0 -11
- package/src/harness/extension-adapter.mjs +1 -0
- package/src/harness/index.mjs +0 -1
- package/src/loop/bash-command-classify.mjs +333 -29
- package/src/loop/conductor-routing.mjs +0 -27
- package/src/loop/copilot-loop-state.mjs +25 -2
- package/src/loop/gate-fanin.mjs +92 -0
- package/src/loop/handoff-envelope.mjs +142 -70
- package/src/loop/issue-refinement-artifact.mjs +236 -8
- package/src/loop/lifecycle-state.mjs +1 -1
- package/src/loop/pr-gate-coordination.mjs +49 -238
- package/src/loop/public-dev-loop-routing.mjs +2 -2
- package/src/loop/queue-board-ordering.mjs +51 -7
- package/src/loop/queue-board-sync.mjs +61 -2
- package/src/loop/queue-driver.mjs +80 -8
- package/src/loop/queue-state.mjs +13 -2
- package/bin/capture-deep-persona-signals.mjs +0 -143
- package/src/debt/deep-persona-signals.mjs +0 -266
- package/src/harness/claude-extension-adapter.mjs +0 -102
- package/src/refinement/ac-dod-matrix.mjs +0 -95
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
transitionEntry,
|
|
10
10
|
snapshotEntry,
|
|
11
11
|
nextReadyEntry,
|
|
12
|
+
findEntry,
|
|
12
13
|
allDone,
|
|
13
14
|
RECOVERABLE_FAILURES,
|
|
14
15
|
appendBugIssue,
|
|
@@ -19,7 +20,13 @@ import {
|
|
|
19
20
|
boardColumnForLoopState,
|
|
20
21
|
loadStateColumnMap,
|
|
21
22
|
} from "./queue-board-sync.mjs";
|
|
22
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
resolveNextUpOrder,
|
|
25
|
+
REASON_NEXT_UP_EMPTY,
|
|
26
|
+
REASON_BOARD_QUERY_ERROR,
|
|
27
|
+
REASON_NEXT_UP_TARGET_MISSING_LOCALLY,
|
|
28
|
+
EMPTY_NEXT_UP_MESSAGE,
|
|
29
|
+
} from "./queue-board-ordering.mjs";
|
|
23
30
|
|
|
24
31
|
export const DEFAULT_QUEUE_DRIVER_OPTIONS = {
|
|
25
32
|
mergeAuthorized: false,
|
|
@@ -92,23 +99,88 @@ export async function runQueue(repoRoot, repo, options = {}) {
|
|
|
92
99
|
// (e.g. a configured "Ready for Review") still syncs. (#793 round-1 #1)
|
|
93
100
|
const lastSyncedColumn = new Map();
|
|
94
101
|
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
102
|
+
// Next Up is the NORMATIVE, fail-closed pickup source (#1091). When a board is
|
|
103
|
+
// configured, the driver picks ONLY entries whose target is in Next Up, by
|
|
104
|
+
// POSITION ascending; entries absent from Next Up are never auto-picked. It
|
|
105
|
+
// NEVER falls back to Backlog or to the non-board local queue order.
|
|
106
|
+
//
|
|
107
|
+
// Single-issue/PR runs do not reach this gating at all — they run via the
|
|
108
|
+
// dev-loop routing path, not the queue driver — so an explicit --issue/--pr
|
|
109
|
+
// target is inherently unaffected by Next Up.
|
|
110
|
+
const ordering = !allDone(queue)
|
|
99
111
|
? await resolveNextUpOrder(repo, repoRoot, opts.env ?? process.env, opts.queueBoardSyncDependencies ?? {})
|
|
100
|
-
: { ok: true, order: [], reason: "
|
|
101
|
-
|
|
112
|
+
: { ok: true, configured: false, order: [], reason: "queue idle" };
|
|
113
|
+
|
|
114
|
+
// (b) Board-query ERROR → surface it and stop. Do NOT fall back to Backlog
|
|
115
|
+
// or local order (fail-closed). Distinct from an empty Next Up below.
|
|
116
|
+
if (ordering.ok === false) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
stopped: true,
|
|
120
|
+
reason: REASON_BOARD_QUERY_ERROR,
|
|
121
|
+
message: `Next Up query failed (${ordering.reason}); refusing to fall back to Backlog/local order`,
|
|
122
|
+
error: ordering.reason ?? "board query failed",
|
|
123
|
+
results: [],
|
|
124
|
+
queue,
|
|
125
|
+
ordering,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Board-gated only when a board is configured.
|
|
130
|
+
const boardGated = ordering.configured === true;
|
|
131
|
+
const orderHint = ordering.order;
|
|
132
|
+
const allowedTargets = boardGated ? new Set(orderHint) : null;
|
|
133
|
+
|
|
134
|
+
// (a) Empty Next Up (successful query, zero items) → fail CLOSED: idle/stop
|
|
135
|
+
// with an actionable, machine-readable outcome. Never pull from Backlog.
|
|
136
|
+
if (boardGated && orderHint.length === 0) {
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
idle: true,
|
|
140
|
+
reason: REASON_NEXT_UP_EMPTY,
|
|
141
|
+
message: EMPTY_NEXT_UP_MESSAGE,
|
|
142
|
+
results: [],
|
|
143
|
+
queue,
|
|
144
|
+
ordering,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// (a2) Next Up resolved one or more targets that have NO matching local queue
|
|
149
|
+
// entry (membership reconcile not run/persisted, or the board changed between
|
|
150
|
+
// reconcile and this query). Filtering them out would return a silent empty
|
|
151
|
+
// idle while real Next Up work goes undispatched — so fail CLOSED with an
|
|
152
|
+
// actionable stop instead. Distinct from the genuine empty-Next-Up idle above.
|
|
153
|
+
// Never pull from Backlog. (#1091)
|
|
154
|
+
if (boardGated) {
|
|
155
|
+
const missingTargets = orderHint.filter((t) => !findEntry(queue, t));
|
|
156
|
+
if (missingTargets.length > 0) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
stopped: true,
|
|
160
|
+
reason: REASON_NEXT_UP_TARGET_MISSING_LOCALLY,
|
|
161
|
+
missingTargets,
|
|
162
|
+
message:
|
|
163
|
+
"Next Up contains items with no local queue entry — run membership reconcile / re-add them",
|
|
164
|
+
results: [],
|
|
165
|
+
queue,
|
|
166
|
+
ordering,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
102
170
|
|
|
103
171
|
let autoFiledCount = 0;
|
|
104
172
|
const results = [];
|
|
105
173
|
let incomplete = false;
|
|
106
174
|
|
|
107
175
|
while (!allDone(queue)) {
|
|
108
|
-
const entry = nextReadyEntry(queue, opts.reDispatchMaxRetries, orderHint);
|
|
176
|
+
const entry = nextReadyEntry(queue, opts.reDispatchMaxRetries, orderHint, allowedTargets);
|
|
109
177
|
if (!entry) {
|
|
178
|
+
// When board-gated, entries absent from Next Up are intentionally NOT
|
|
179
|
+
// picked (and are not "blocked by deps") — only unfinished Next Up members
|
|
180
|
+
// count toward an incomplete verdict.
|
|
110
181
|
const remaining = queue.entries.filter(
|
|
111
182
|
(e) => e.status !== "done" && e.status !== "blocked" && e.status !== "failed"
|
|
183
|
+
&& (!allowedTargets || allowedTargets.has(e.target))
|
|
112
184
|
);
|
|
113
185
|
if (remaining.length > 0) {
|
|
114
186
|
incomplete = true;
|
package/src/loop/queue-state.mjs
CHANGED
|
@@ -183,9 +183,20 @@ function applyOrderHint(ordered, orderHint) {
|
|
|
183
183
|
return [...inHint, ...rest];
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Pick the next ready entry.
|
|
188
|
+
*
|
|
189
|
+
* @param {object} queue
|
|
190
|
+
* @param {number} maxRetries
|
|
191
|
+
* @param {number[]} orderHint - preferred order (targets sorted to the front).
|
|
192
|
+
* @param {Set<number>|null} allowedTargets - when non-null, ONLY entries whose
|
|
193
|
+
* target is in this set are eligible. Used for board-gated (Next Up)
|
|
194
|
+
* selection (#1091): entries absent from Next Up are never auto-picked.
|
|
195
|
+
*/
|
|
196
|
+
export function nextReadyEntry(queue, maxRetries = 1, orderHint = [], allowedTargets = null) {
|
|
187
197
|
const ordered = topologicalOrder(queue.entries);
|
|
188
|
-
const
|
|
198
|
+
const restricted = allowedTargets ? ordered.filter((e) => allowedTargets.has(e.target)) : ordered;
|
|
199
|
+
const sorted = applyOrderHint(restricted, orderHint);
|
|
189
200
|
for (const entry of sorted) {
|
|
190
201
|
if (entry.status === "queued" && entryDependenciesSatisfied(queue, entry)) {
|
|
191
202
|
return entry;
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
-
import { join, resolve } from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { parseReviewThreads, readInput, parseJsonText, formatCliError } from "../src/github/review-threads.mjs";
|
|
6
|
-
import { extractDeepPersonaSignals } from "../src/debt/deep-persona-signals.mjs";
|
|
7
|
-
|
|
8
|
-
export const USAGE = [
|
|
9
|
-
"Usage: capture-deep-persona-signals.mjs --input <path> --pr-number <n> --pr-url <url> [--output-dir <path>]",
|
|
10
|
-
"",
|
|
11
|
-
"Arguments:",
|
|
12
|
-
" --input <path> Path to normalized review-thread JSON (required)",
|
|
13
|
-
" --pr-number <n> PR number for metadata (required)",
|
|
14
|
-
" --pr-url <url> PR URL for metadata (required)",
|
|
15
|
-
" --output-dir <path> Output directory for emitted artifact (default: .pi/debt/signals/)",
|
|
16
|
-
].join("\n");
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Parse CLI arguments for the capture-deep-persona-signals CLI.
|
|
20
|
-
*
|
|
21
|
-
* @param {string[]} argv - Argument list (e.g. process.argv.slice(2))
|
|
22
|
-
* @returns {{ inputPath: string, prNumber: string, prUrl: string, outputDir: string }}
|
|
23
|
-
*/
|
|
24
|
-
export function parseArgs(argv) {
|
|
25
|
-
const args = [...argv];
|
|
26
|
-
const options = {
|
|
27
|
-
inputPath: undefined,
|
|
28
|
-
prNumber: undefined,
|
|
29
|
-
prUrl: undefined,
|
|
30
|
-
outputDir: ".pi/debt/signals",
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
while (args.length > 0) {
|
|
34
|
-
const token = args.shift();
|
|
35
|
-
|
|
36
|
-
switch (token) {
|
|
37
|
-
case "--input": {
|
|
38
|
-
const value = args.shift();
|
|
39
|
-
if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
|
|
40
|
-
throw Object.assign(new Error("Missing value for --input"), { usage: USAGE });
|
|
41
|
-
}
|
|
42
|
-
options.inputPath = value;
|
|
43
|
-
break;
|
|
44
|
-
}
|
|
45
|
-
case "--pr-number": {
|
|
46
|
-
const value = args.shift();
|
|
47
|
-
if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
|
|
48
|
-
throw Object.assign(new Error("Missing value for --pr-number"), { usage: USAGE });
|
|
49
|
-
}
|
|
50
|
-
if (!/^\d+$/.test(value)) {
|
|
51
|
-
throw Object.assign(new Error(`--pr-number must be a positive integer, got: ${value}`), { usage: USAGE });
|
|
52
|
-
}
|
|
53
|
-
options.prNumber = value;
|
|
54
|
-
break;
|
|
55
|
-
}
|
|
56
|
-
case "--pr-url": {
|
|
57
|
-
const value = args.shift();
|
|
58
|
-
if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
|
|
59
|
-
throw Object.assign(new Error("Missing value for --pr-url"), { usage: USAGE });
|
|
60
|
-
}
|
|
61
|
-
options.prUrl = value;
|
|
62
|
-
break;
|
|
63
|
-
}
|
|
64
|
-
case "--output-dir": {
|
|
65
|
-
const value = args.shift();
|
|
66
|
-
if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
|
|
67
|
-
throw Object.assign(new Error("Missing value for --output-dir"), { usage: USAGE });
|
|
68
|
-
}
|
|
69
|
-
options.outputDir = value;
|
|
70
|
-
break;
|
|
71
|
-
}
|
|
72
|
-
default:
|
|
73
|
-
throw Object.assign(new Error(`Unknown argument: ${token}`), { usage: USAGE });
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (!options.inputPath) {
|
|
78
|
-
throw Object.assign(new Error("--input is required"), { usage: USAGE });
|
|
79
|
-
}
|
|
80
|
-
if (!options.prNumber) {
|
|
81
|
-
throw Object.assign(new Error("--pr-number is required"), { usage: USAGE });
|
|
82
|
-
}
|
|
83
|
-
if (!options.prUrl) {
|
|
84
|
-
throw Object.assign(new Error("--pr-url is required"), { usage: USAGE });
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return /** @type {{ inputPath: string, prNumber: string, prUrl: string, outputDir: string }} */ (options);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Generate the output filename.
|
|
92
|
-
* @param {string} prNumber
|
|
93
|
-
* @returns {string}
|
|
94
|
-
*/
|
|
95
|
-
export function outputFilename(prNumber) {
|
|
96
|
-
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
97
|
-
return `deep-persona-signals-${prNumber}-${ts}.json`;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export async function run(argv = process.argv.slice(2)) {
|
|
101
|
-
const options = parseArgs(argv);
|
|
102
|
-
|
|
103
|
-
// Read and parse the review-thread input
|
|
104
|
-
const rawText = await readInput({ inputPath: options.inputPath });
|
|
105
|
-
const parsed = parseReviewThreads(parseJsonText(rawText));
|
|
106
|
-
|
|
107
|
-
// Extract deep-persona signals
|
|
108
|
-
const signals = extractDeepPersonaSignals(parsed, {
|
|
109
|
-
prNumber: options.prNumber,
|
|
110
|
-
prUrl: options.prUrl,
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// Build artifact envelope
|
|
114
|
-
const artifact = {
|
|
115
|
-
version: 1,
|
|
116
|
-
generatedAt: new Date().toISOString(),
|
|
117
|
-
prNumber: Number(options.prNumber),
|
|
118
|
-
prUrl: options.prUrl,
|
|
119
|
-
source: "pr_review_deep_persona",
|
|
120
|
-
signalCount: signals.length,
|
|
121
|
-
signals,
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
// Write to output directory
|
|
125
|
-
const outDir = resolve(options.outputDir);
|
|
126
|
-
await mkdir(outDir, { recursive: true });
|
|
127
|
-
const outPath = join(outDir, outputFilename(options.prNumber));
|
|
128
|
-
await writeFile(outPath, JSON.stringify(artifact, null, 2) + "\n", "utf8");
|
|
129
|
-
|
|
130
|
-
process.stdout.write(JSON.stringify({ ok: true, outputPath: outPath, signalCount: signals.length }) + "\n");
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Only auto-run when executed directly (not imported)
|
|
134
|
-
const scriptPath = fileURLToPath(import.meta.url);
|
|
135
|
-
if (process.argv[1] === scriptPath) {
|
|
136
|
-
run().catch((error) => {
|
|
137
|
-
if (error.usage) {
|
|
138
|
-
process.stderr.write(error.usage + "\n\n");
|
|
139
|
-
}
|
|
140
|
-
process.stderr.write(formatCliError(error) + "\n");
|
|
141
|
-
process.exitCode = 1;
|
|
142
|
-
});
|
|
143
|
-
}
|
|
@@ -1,266 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
|
|
3
|
-
import { createExtensionHarnessAdapter } from "./extension-adapter.mjs";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Create the Claude Code extension-surface adapter.
|
|
7
|
-
*
|
|
8
|
-
* Implements the same `ExtensionHarnessAdapter` interface as the Pi adapter so
|
|
9
|
-
* `executeDevLoopsCommand` and the extension wiring run unchanged under Claude.
|
|
10
|
-
*
|
|
11
|
-
* - `exec` shells out via `bash -lc` (Claude has no native exec API in core).
|
|
12
|
-
* - lifecycle `on(...)` registrations are stored in `listeners` for hook-driven
|
|
13
|
-
* dispatch (wired in CA4 / #773); they are not auto-fired here.
|
|
14
|
-
* - `registerCommand(...)` registrations are stored in `commands`.
|
|
15
|
-
* - Claude core has no interactive widget/status surface, so the default
|
|
16
|
-
* `HarnessContext` reports `hasUI: false` and routes `ui` calls to a sink.
|
|
17
|
-
*
|
|
18
|
-
* @param {Object} [options]
|
|
19
|
-
* @param {string} [options.cwd] - Default cwd for exec and contexts (default: process.cwd()).
|
|
20
|
-
* @param {NodeJS.ProcessEnv} [options.env] - Env for exec (default: process.env).
|
|
21
|
-
* @param {(message: string, level: string) => void} [options.onNotify] - Optional sink for
|
|
22
|
-
* `ui.notify` (e.g. console). Defaults to a no-op.
|
|
23
|
-
* @returns {import("./extension-adapter.mjs").ExtensionHarnessAdapter & {
|
|
24
|
-
* listeners: Map<string, Function>,
|
|
25
|
-
* commands: Map<string, import("./extension-adapter.mjs").HarnessCommandConfig>,
|
|
26
|
-
* makeContext: (overrides?: {cwd?: string}) => import("./extension-adapter.mjs").HarnessContext,
|
|
27
|
-
* }}
|
|
28
|
-
*/
|
|
29
|
-
export function createClaudeExtensionAdapter({
|
|
30
|
-
cwd = process.cwd(),
|
|
31
|
-
env = process.env,
|
|
32
|
-
onNotify = () => {},
|
|
33
|
-
} = {}) {
|
|
34
|
-
const listeners = new Map();
|
|
35
|
-
const commands = new Map();
|
|
36
|
-
|
|
37
|
-
function exec(command, options = {}) {
|
|
38
|
-
return new Promise((resolve) => {
|
|
39
|
-
execFile(
|
|
40
|
-
"bash",
|
|
41
|
-
["-lc", command],
|
|
42
|
-
{
|
|
43
|
-
cwd: options.cwd ?? cwd,
|
|
44
|
-
env,
|
|
45
|
-
timeout: options.timeout ?? 0,
|
|
46
|
-
encoding: "utf8",
|
|
47
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
48
|
-
},
|
|
49
|
-
(error, stdout, stderr) => {
|
|
50
|
-
if (error) {
|
|
51
|
-
// Match the documented HarnessExecResult contract: `code` is undefined when
|
|
52
|
-
// the process was killed (e.g. timeout). Consumers branch on `killed` first.
|
|
53
|
-
const killed = Boolean(error.killed);
|
|
54
|
-
resolve({
|
|
55
|
-
code: killed ? undefined : (typeof error.code === "number" ? error.code : 1),
|
|
56
|
-
stdout: stdout ?? "",
|
|
57
|
-
stderr: stderr ?? "",
|
|
58
|
-
killed,
|
|
59
|
-
});
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
resolve({ code: 0, stdout: stdout ?? "", stderr: stderr ?? "", killed: false });
|
|
63
|
-
},
|
|
64
|
-
);
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function makeContext({ cwd: ctxCwd } = {}) {
|
|
69
|
-
return {
|
|
70
|
-
cwd: ctxCwd ?? cwd,
|
|
71
|
-
hasUI: false,
|
|
72
|
-
ui: {
|
|
73
|
-
notify(message, level = "info") {
|
|
74
|
-
onNotify(message, level);
|
|
75
|
-
},
|
|
76
|
-
setWidget() {},
|
|
77
|
-
setStatus() {},
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const adapter = createExtensionHarnessAdapter({
|
|
83
|
-
exec,
|
|
84
|
-
on(event, handler) {
|
|
85
|
-
listeners.set(event, handler);
|
|
86
|
-
},
|
|
87
|
-
registerCommand(name, config) {
|
|
88
|
-
commands.set(name, config);
|
|
89
|
-
},
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
// Freeze the composite so the validated interface guarantee from the factory carries
|
|
93
|
-
// through to the returned object (which also exposes the #773 dispatch registries).
|
|
94
|
-
return Object.freeze({
|
|
95
|
-
exec: adapter.exec,
|
|
96
|
-
on: adapter.on,
|
|
97
|
-
registerCommand: adapter.registerCommand,
|
|
98
|
-
listeners,
|
|
99
|
-
commands,
|
|
100
|
-
makeContext,
|
|
101
|
-
});
|
|
102
|
-
}
|