@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.
- package/bin/capture-deep-persona-signals.mjs +143 -0
- package/bin/ensure-phase-files.mjs +7 -0
- package/bin/log-bash-exit-1.mjs +7 -0
- package/bin/parse-review-threads.mjs +7 -0
- package/package.json +78 -0
- package/src/analysis/change-classifier.mjs +146 -0
- package/src/analysis/diff-analyzer.mjs +285 -0
- package/src/bash-exit-one.mjs +130 -0
- package/src/cli/helpers.mjs +22 -0
- package/src/cli/primitives.mjs +70 -0
- package/src/cli/retry-wrapper.mjs +169 -0
- package/src/cli/subcommand-runner.mjs +246 -0
- package/src/config/config.mjs +965 -0
- package/src/debt/cluster.mjs +240 -0
- package/src/debt/debt-finding.mjs +68 -0
- package/src/debt/debt-signal.mjs +46 -0
- package/src/debt/deep-persona-signals.mjs +266 -0
- package/src/debt/remediation-to-issue.mjs +121 -0
- package/src/debt/score.mjs +127 -0
- package/src/debt/shape.mjs +214 -0
- package/src/github/copilot-helpers.mjs +343 -0
- package/src/github/repo-slug.mjs +105 -0
- package/src/github/review-threads.mjs +343 -0
- package/src/harness/adapter.mjs +57 -0
- package/src/harness/index.mjs +3 -0
- package/src/harness/noop-adapter.mjs +22 -0
- package/src/harness/pi-adapter.mjs +47 -0
- package/src/loop/async-start-contract.mjs +170 -0
- package/src/loop/conductor-routing.mjs +817 -0
- package/src/loop/copilot-ci-status.mjs +255 -0
- package/src/loop/copilot-loop-iterations.mjs +161 -0
- package/src/loop/copilot-loop-state.mjs +510 -0
- package/src/loop/handoff-envelope.mjs +800 -0
- package/src/loop/issue-refinement-artifact.mjs +268 -0
- package/src/loop/lifecycle-state.mjs +342 -0
- package/src/loop/phase-files.mjs +187 -0
- package/src/loop/policy-constants.mjs +17 -0
- package/src/loop/pr-gate-coordination.mjs +1278 -0
- package/src/loop/public-dev-loop-routing-contract.mjs +277 -0
- package/src/loop/public-dev-loop-routing.mjs +1746 -0
- package/src/loop/queue-board-ordering.mjs +38 -0
- package/src/loop/queue-board-sync.mjs +223 -0
- package/src/loop/queue-driver.mjs +164 -0
- package/src/loop/queue-parallel.mjs +190 -0
- package/src/loop/queue-state.mjs +230 -0
- package/src/loop/retrospective-checkpoint.mjs +178 -0
- package/src/loop/reviewer-loop-state.mjs +456 -0
- package/src/loop/run-inspection.mjs +604 -0
- package/src/loop/steering.mjs +793 -0
- package/src/loop/timeout-policy.mjs +73 -0
- package/src/loop/tracker-first-loop-state.mjs +87 -0
- package/src/loop/tracker-pr-state.mjs +301 -0
- package/src/loop/worktree-guard.mjs +141 -0
- package/src/refinement/ac-dod-matrix.mjs +95 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
function normalizeId(value, fallback) {
|
|
4
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
5
|
+
return value.trim();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
9
|
+
return String(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return fallback;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeBody(value) {
|
|
16
|
+
if (typeof value !== "string") {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return value.trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeAuthor(author) {
|
|
24
|
+
if (!author || typeof author !== "object") {
|
|
25
|
+
return {
|
|
26
|
+
login: "",
|
|
27
|
+
type: "System",
|
|
28
|
+
isBot: false,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const login = typeof author.login === "string" ? author.login.trim() : "";
|
|
33
|
+
const type = typeof author.__typename === "string"
|
|
34
|
+
? author.__typename.trim()
|
|
35
|
+
: typeof author.type === "string"
|
|
36
|
+
? author.type.trim()
|
|
37
|
+
: "User";
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
login,
|
|
41
|
+
type,
|
|
42
|
+
isBot: Boolean(author.isBot) || type === "Bot" || login.endsWith("[bot]"),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractRawComments(thread) {
|
|
47
|
+
if (Array.isArray(thread?.comments)) {
|
|
48
|
+
return thread.comments;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (Array.isArray(thread?.comments?.nodes)) {
|
|
52
|
+
return thread.comments.nodes;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function extractRawThreads(payload) {
|
|
59
|
+
if (Array.isArray(payload)) {
|
|
60
|
+
return payload;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const candidates = [
|
|
64
|
+
payload?.threads,
|
|
65
|
+
payload?.reviewThreads,
|
|
66
|
+
payload?.reviewThreads?.nodes,
|
|
67
|
+
payload?.data?.repository?.pullRequest?.reviewThreads?.nodes,
|
|
68
|
+
payload?.data?.node?.reviewThreads?.nodes,
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
for (const candidate of candidates) {
|
|
72
|
+
if (Array.isArray(candidate)) {
|
|
73
|
+
return candidate;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
throw new Error("Could not find review threads in payload");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function compareIds(left, right) {
|
|
81
|
+
return left.localeCompare(right, undefined, { numeric: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isActionableComment(comment) {
|
|
85
|
+
const body = normalizeBody(comment?.body ?? comment?.bodyText ?? comment?.bodyHTML ?? "");
|
|
86
|
+
const author = normalizeAuthor(comment?.author);
|
|
87
|
+
|
|
88
|
+
return body.length > 0 && author.login.length > 0 && author.type !== "System" && !author.isBot;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function isActionableThread(thread) {
|
|
92
|
+
if (Boolean(thread?.isResolved)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return extractRawComments(thread).some((comment) => isActionableComment(comment));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeComment(comment, threadId, index, { isResolved = false } = {}) {
|
|
100
|
+
const author = normalizeAuthor(comment?.author);
|
|
101
|
+
const body = normalizeBody(comment?.body ?? comment?.bodyText ?? comment?.bodyHTML ?? "");
|
|
102
|
+
const databaseId = comment?.databaseId === null || comment?.databaseId === undefined
|
|
103
|
+
? null
|
|
104
|
+
: normalizeId(comment.databaseId, null);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
id: normalizeId(comment?.id ?? comment?.databaseId, `${threadId}:comment-${index + 1}`),
|
|
108
|
+
databaseId,
|
|
109
|
+
threadId,
|
|
110
|
+
author,
|
|
111
|
+
body,
|
|
112
|
+
isActionable: !isResolved && body.length > 0 && author.login.length > 0 && author.type !== "System" && !author.isBot,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function parseReviewThreads(payload) {
|
|
117
|
+
const rawThreads = extractRawThreads(payload);
|
|
118
|
+
const comments = [];
|
|
119
|
+
const threads = rawThreads.map((thread, threadIndex) => {
|
|
120
|
+
const threadId = normalizeId(thread?.id ?? thread?.databaseId, `thread-${threadIndex + 1}`);
|
|
121
|
+
const isResolved = Boolean(thread?.isResolved);
|
|
122
|
+
const normalizedComments = extractRawComments(thread)
|
|
123
|
+
.map((comment, commentIndex) => normalizeComment(comment, threadId, commentIndex, { isResolved }))
|
|
124
|
+
.sort((left, right) => compareIds(left.id, right.id));
|
|
125
|
+
|
|
126
|
+
comments.push(...normalizedComments);
|
|
127
|
+
const commentIds = normalizedComments.map((comment) => comment.id);
|
|
128
|
+
const commentDatabaseIds = normalizedComments
|
|
129
|
+
.map((comment) => comment.databaseId)
|
|
130
|
+
.filter((value) => value !== null);
|
|
131
|
+
const actionableComments = isResolved
|
|
132
|
+
? []
|
|
133
|
+
: normalizedComments.filter((comment) => comment.isActionable);
|
|
134
|
+
const actionableCommentIds = actionableComments.map((comment) => comment.id);
|
|
135
|
+
const actionableCommentDatabaseIds = actionableComments
|
|
136
|
+
.map((comment) => comment.databaseId)
|
|
137
|
+
.filter((value) => value !== null);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
id: threadId,
|
|
141
|
+
isResolved,
|
|
142
|
+
isActionable: actionableCommentIds.length > 0,
|
|
143
|
+
commentIds,
|
|
144
|
+
commentDatabaseIds,
|
|
145
|
+
actionableCommentIds,
|
|
146
|
+
actionableCommentDatabaseIds,
|
|
147
|
+
};
|
|
148
|
+
}).sort((left, right) => compareIds(left.id, right.id));
|
|
149
|
+
|
|
150
|
+
const sortedComments = comments.sort((left, right) => {
|
|
151
|
+
const threadOrder = compareIds(left.threadId, right.threadId);
|
|
152
|
+
return threadOrder === 0 ? compareIds(left.id, right.id) : threadOrder;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
summary: {
|
|
157
|
+
totalThreads: threads.length,
|
|
158
|
+
unresolvedThreads: threads.filter((thread) => !thread.isResolved).length,
|
|
159
|
+
actionableThreads: threads.filter((thread) => thread.isActionable).length,
|
|
160
|
+
actionableComments: threads.reduce((count, thread) => count + thread.actionableCommentIds.length, 0),
|
|
161
|
+
},
|
|
162
|
+
threads,
|
|
163
|
+
comments: sortedComments,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Signal classification heuristics ──────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
const HIGH_SIGNAL_PATTERNS = [
|
|
170
|
+
/\bbug\b/i, /\bcrash\b/i, /\bsecurity\b/i, /\bvulnerab/i,
|
|
171
|
+
/\bcontract\b/i, /\bbroken\b/i, /\bincorrect\b/i, /\bwrong\b/i,
|
|
172
|
+
/\bsilent(?:ly)?\b/i, /\bdata.?loss\b/i, /\brace.?condition\b/i,
|
|
173
|
+
/\bmemory.?leak\b/i, /\binfinite.?loop\b/i, /\bdeadlock\b/i,
|
|
174
|
+
/\bexception\b/i, /\bfatal\b/i, /\bcorrupt/i, /\bdiverg/i,
|
|
175
|
+
/\binconsisten/i, /\bregression\b/i, /\blost\b/i, /\bmissing\b/i,
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
const MID_SIGNAL_PATTERNS = [
|
|
179
|
+
/\brefactor\b/i, /\brestructur/i, /\breorganiz/i,
|
|
180
|
+
/\bperform(?:ance)?\b/i, /\barchitect/i, /\bdesign\b/i,
|
|
181
|
+
/\b(?:should\s+)?consider\b/i, /\b(?:\w+\s+)?maybe\b/i,
|
|
182
|
+
/\balternative\b/i, /\bimprove(?:ment)?\b/i, /\bextract\b/i,
|
|
183
|
+
/\babstract(?:ion)?\b/i, /\bdry\b/i, /\bsimplif/i,
|
|
184
|
+
/\bduplicat/i, /\bunnecessary/i, /\bover.engineer/i,
|
|
185
|
+
/\bcould\b/i, /\bwould\b/i, /\bsuggest/i, /\brecommend/i,
|
|
186
|
+
/\bprefer\b/i, /\bbetter\b/i, /\bclean(?:er)?\b/i,
|
|
187
|
+
/\breus(?:e|able)\b/i, /\btestable/i, /\bconsistent/i,
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Classify a single comment by signal level using heuristic keyword matching.
|
|
192
|
+
* No AI or API confidence data is used.
|
|
193
|
+
*
|
|
194
|
+
* @param {{ body?: string|null }} comment
|
|
195
|
+
* @returns {"high"|"mid"|"low"}
|
|
196
|
+
*/
|
|
197
|
+
export function classifyCommentSignal(comment) {
|
|
198
|
+
const body = (typeof comment?.body === "string" ? comment.body : "").trim();
|
|
199
|
+
if (body.length === 0) return "low";
|
|
200
|
+
|
|
201
|
+
for (const pattern of HIGH_SIGNAL_PATTERNS) {
|
|
202
|
+
if (pattern.test(body)) return "high";
|
|
203
|
+
}
|
|
204
|
+
for (const pattern of MID_SIGNAL_PATTERNS) {
|
|
205
|
+
if (pattern.test(body)) return "mid";
|
|
206
|
+
}
|
|
207
|
+
return "low";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Classify a review thread by its highest comment signal level.
|
|
212
|
+
*
|
|
213
|
+
* @param {{ comments: Array<{ body?: string|null }> }} thread
|
|
214
|
+
* @returns {"high"|"mid"|"low"}
|
|
215
|
+
*/
|
|
216
|
+
export function classifyThreadSignal(thread) {
|
|
217
|
+
const comments = Array.isArray(thread?.comments) ? thread.comments : [];
|
|
218
|
+
let maxSignal = "low";
|
|
219
|
+
for (const comment of comments) {
|
|
220
|
+
const signal = classifyCommentSignal(comment);
|
|
221
|
+
if (signal === "high") return "high";
|
|
222
|
+
if (signal === "mid") maxSignal = "mid";
|
|
223
|
+
}
|
|
224
|
+
return maxSignal;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Determine the maximum signal level across all Copilot-authored threads.
|
|
229
|
+
*
|
|
230
|
+
* Filters threads to those containing at least one Copilot-authored comment,
|
|
231
|
+
* then classifies the signal level using all comments in those threads
|
|
232
|
+
* (including human replies in Copilot threads, which may clarify severity).
|
|
233
|
+
* Returns null if no Copilot-authored threads exist.
|
|
234
|
+
*
|
|
235
|
+
* @param {{ threads: Array<object>, comments: Array<object> }} parsedResult — output of parseReviewThreads()
|
|
236
|
+
* @param {(login: string) => boolean} isCopilotLoginFn — predicate for Copilot authors
|
|
237
|
+
* @returns {"high"|"mid"|"low"|null}
|
|
238
|
+
*/
|
|
239
|
+
export function classifyReviewThreadsSignal(parsedResult, isCopilotLoginFn) {
|
|
240
|
+
const threads = Array.isArray(parsedResult?.threads) ? parsedResult.threads : [];
|
|
241
|
+
const flatComments = Array.isArray(parsedResult?.comments) ? parsedResult.comments : [];
|
|
242
|
+
if (flatComments.length === 0) return null;
|
|
243
|
+
|
|
244
|
+
// Group comments by threadId
|
|
245
|
+
const commentsByThread = new Map();
|
|
246
|
+
for (const comment of flatComments) {
|
|
247
|
+
const tid = comment.threadId ?? "unknown";
|
|
248
|
+
if (!commentsByThread.has(tid)) commentsByThread.set(tid, []);
|
|
249
|
+
commentsByThread.get(tid).push(comment);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let maxSignal = null;
|
|
253
|
+
for (const thread of threads) {
|
|
254
|
+
const threadComments = commentsByThread.get(thread.id) ?? [];
|
|
255
|
+
const hasCopilotComment = threadComments.some(
|
|
256
|
+
(c) => typeof c?.author?.login === "string" && isCopilotLoginFn(c.author.login),
|
|
257
|
+
);
|
|
258
|
+
if (!hasCopilotComment) continue;
|
|
259
|
+
const signal = classifyThreadSignal({ comments: threadComments });
|
|
260
|
+
if (signal === "high") return "high";
|
|
261
|
+
if (maxSignal === null || (signal === "mid" && maxSignal === "low")) {
|
|
262
|
+
maxSignal = signal;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return maxSignal;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
function requireOptionValue(args, flag) {
|
|
270
|
+
const value = args.shift();
|
|
271
|
+
|
|
272
|
+
if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
|
|
273
|
+
throw new Error(`Missing value for ${flag}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function parseCliArgs(argv) {
|
|
280
|
+
const args = [...argv];
|
|
281
|
+
const options = {
|
|
282
|
+
inputPath: undefined,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
while (args.length > 0) {
|
|
286
|
+
const token = args.shift();
|
|
287
|
+
|
|
288
|
+
if (token === "--input") {
|
|
289
|
+
options.inputPath = requireOptionValue(args, "--input");
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
throw new Error(`Unknown argument: ${token}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return options;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function readInput({ inputPath, stdin = process.stdin } = {}) {
|
|
300
|
+
if (inputPath) {
|
|
301
|
+
return readFile(inputPath, "utf8");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let input = "";
|
|
305
|
+
for await (const chunk of stdin) {
|
|
306
|
+
input += chunk;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (input.trim().length === 0) {
|
|
310
|
+
throw new Error("Expected review-thread JSON via --input <path> or stdin");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return input;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function parseJsonText(text) {
|
|
317
|
+
try {
|
|
318
|
+
return JSON.parse(text);
|
|
319
|
+
} catch {
|
|
320
|
+
throw new Error("Invalid JSON input");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function formatCliError(error) {
|
|
325
|
+
const payload = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
326
|
+
if (error instanceof Error && typeof error.usage === "string") {
|
|
327
|
+
payload.usage = error.usage;
|
|
328
|
+
}
|
|
329
|
+
return JSON.stringify(payload);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function runCli(
|
|
333
|
+
argv = process.argv.slice(2),
|
|
334
|
+
{
|
|
335
|
+
stdin = process.stdin,
|
|
336
|
+
stdout = process.stdout,
|
|
337
|
+
} = {},
|
|
338
|
+
) {
|
|
339
|
+
const options = parseCliArgs(argv);
|
|
340
|
+
const text = await readInput({ inputPath: options.inputPath, stdin });
|
|
341
|
+
const result = parseReviewThreads(parseJsonText(text));
|
|
342
|
+
stdout.write(`${JSON.stringify({ ok: true, ...result })}\n`);
|
|
343
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness adapter interface.
|
|
3
|
+
*
|
|
4
|
+
* Abstracts runtime concerns that vary between agent harnesses (Pi, Claude,
|
|
5
|
+
* local CLI, test) so the dev-loop dispatch and handoff path stays harness-agnostic.
|
|
6
|
+
*
|
|
7
|
+
* This slice is intentionally minimal. Add methods only when the dispatch/handoff
|
|
8
|
+
* path needs them; do not turn the adapter into a generic process wrapper.
|
|
9
|
+
*
|
|
10
|
+
* @typedef {Object} HarnessAdapter
|
|
11
|
+
* @property {() => string} getCwd - Current working directory for the active session.
|
|
12
|
+
* @property {() => NodeJS.ProcessEnv} getEnv - Active environment variables.
|
|
13
|
+
* @property {() => boolean} isInteractive - Whether the session is interactive (vs batch/automated).
|
|
14
|
+
* @property {() => boolean} isInsidePi - Whether the session is running inside the Pi agent harness.
|
|
15
|
+
* @property {() => string} getRepoRoot - Best-effort repository root; falls back to cwd.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const REQUIRED_METHODS = ["getCwd", "getEnv", "isInteractive", "isInsidePi", "getRepoRoot"];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validate and freeze a harness-adapter implementation.
|
|
22
|
+
*
|
|
23
|
+
* @param {Partial<HarnessAdapter>} impl
|
|
24
|
+
* @returns {HarnessAdapter}
|
|
25
|
+
*/
|
|
26
|
+
export function createHarnessAdapter(impl) {
|
|
27
|
+
if (!impl || typeof impl !== "object") {
|
|
28
|
+
throw new TypeError("createHarnessAdapter: impl must be an object");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const method of REQUIRED_METHODS) {
|
|
32
|
+
if (typeof impl[method] !== "function") {
|
|
33
|
+
throw new TypeError(`createHarnessAdapter: missing required method "${method}"`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return Object.freeze({
|
|
38
|
+
getCwd: impl.getCwd,
|
|
39
|
+
getEnv: impl.getEnv,
|
|
40
|
+
isInteractive: impl.isInteractive,
|
|
41
|
+
isInsidePi: impl.isInsidePi,
|
|
42
|
+
getRepoRoot: impl.getRepoRoot,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Type guard for adapter values.
|
|
48
|
+
*
|
|
49
|
+
* @param {*} value
|
|
50
|
+
* @returns {value is HarnessAdapter}
|
|
51
|
+
*/
|
|
52
|
+
export function isHarnessAdapter(value) {
|
|
53
|
+
if (!value || typeof value !== "object") {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return REQUIRED_METHODS.every((method) => typeof value[method] === "function");
|
|
57
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createHarnessAdapter } from "./adapter.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a minimal harness adapter for tests and fallback/CI/batch contexts.
|
|
5
|
+
*
|
|
6
|
+
* Defaults mirror the current process so swapping from the Pi adapter does not
|
|
7
|
+
* unexpectedly change behavior; callers can still pin cwd/env for determinism.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} [options]
|
|
10
|
+
* @param {string} [options.cwd]
|
|
11
|
+
* @param {NodeJS.ProcessEnv} [options.env]
|
|
12
|
+
* @returns {import("./adapter.mjs").HarnessAdapter}
|
|
13
|
+
*/
|
|
14
|
+
export function createNoopAdapter({ cwd = process.cwd(), env = process.env } = {}) {
|
|
15
|
+
return createHarnessAdapter({
|
|
16
|
+
getCwd: () => cwd,
|
|
17
|
+
getEnv: () => env,
|
|
18
|
+
isInteractive: () => false,
|
|
19
|
+
isInsidePi: () => false,
|
|
20
|
+
getRepoRoot: () => cwd,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHarnessAdapter } from "./adapter.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create the concrete Pi harness adapter.
|
|
6
|
+
*
|
|
7
|
+
* This is the only active adapter for #765. Future harnesses (Claude, etc.)
|
|
8
|
+
* can implement the same interface without changing call sites.
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} [options]
|
|
11
|
+
* @param {string} [options.cwd] - Override cwd (default: process.cwd()).
|
|
12
|
+
* @param {NodeJS.ProcessEnv} [options.env] - Override env (default: process.env).
|
|
13
|
+
* @returns {import("./adapter.mjs").HarnessAdapter}
|
|
14
|
+
*/
|
|
15
|
+
export function createPiAdapter({ cwd = process.cwd(), env = process.env } = {}) {
|
|
16
|
+
function getRepoRoot() {
|
|
17
|
+
try {
|
|
18
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
19
|
+
cwd,
|
|
20
|
+
env,
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
23
|
+
}).trim();
|
|
24
|
+
} catch {
|
|
25
|
+
return cwd;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isInteractive() {
|
|
30
|
+
if (env.PI_INTERACTIVE === "0") return false;
|
|
31
|
+
if (env.PI_INTERACTIVE === "1") return true;
|
|
32
|
+
if (env.CI === "true" || env.CI === "1") return false;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isInsidePi() {
|
|
37
|
+
return env.PI_SESSION === "1" || typeof globalThis.pi !== "undefined";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return createHarnessAdapter({
|
|
41
|
+
getCwd: () => cwd,
|
|
42
|
+
getEnv: () => env,
|
|
43
|
+
isInteractive,
|
|
44
|
+
isInsidePi,
|
|
45
|
+
getRepoRoot,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async-start contract enforcement for the dev-loop startup path.
|
|
3
|
+
*
|
|
4
|
+
* This module enforces the requirement that dev-loop execution scripts
|
|
5
|
+
* (outer-loop, watch-cycle, etc.) must run within a visible Pi-managed async
|
|
6
|
+
* context rather than as detached local processes (nohup, disowned shell jobs,
|
|
7
|
+
* tmux/screen sessions, ad hoc while/sleep loops, etc.).
|
|
8
|
+
*
|
|
9
|
+
* The enforcement seam is a startup check that verifies the presence of a
|
|
10
|
+
* Pi async context marker. When the marker is absent, the check fails closed
|
|
11
|
+
* and returns a machine-readable rejection rather than silently proceeding.
|
|
12
|
+
*
|
|
13
|
+
* Pi-managed async context markers (required when workflow.asyncStartMode is
|
|
14
|
+
* `required`):
|
|
15
|
+
* - PI_SUBAGENT_RUN_ID env var (set by Pi subagent framework for inspectable async runs)
|
|
16
|
+
*
|
|
17
|
+
* Allowed modes:
|
|
18
|
+
* - workflow.asyncStartMode: required | allowed
|
|
19
|
+
* - Snapshot/test mode (when both --copilot-input and --reviewer-input are provided)
|
|
20
|
+
* implicitly skips the check since no real async ownership is needed
|
|
21
|
+
*
|
|
22
|
+
* This module is intentionally pure and side-effect free.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Constants
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** Environment variable names that indicate a Pi-managed async context. */
|
|
30
|
+
export const PI_ASYNC_CONTEXT_MARKERS = Object.freeze([
|
|
31
|
+
"PI_SUBAGENT_RUN_ID",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/** Supported workflow async-start modes. */
|
|
35
|
+
export const ASYNC_START_MODE = Object.freeze({
|
|
36
|
+
REQUIRED: "required",
|
|
37
|
+
ALLOWED: "allowed",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** Async-start validation result status values. */
|
|
41
|
+
export const ASYNC_START_STATUS = Object.freeze({
|
|
42
|
+
/** A valid Pi-managed async context was detected. */
|
|
43
|
+
VALID: "valid",
|
|
44
|
+
/** The workflow explicitly allows non-async startup for this context. */
|
|
45
|
+
ALLOWED: "allowed",
|
|
46
|
+
/** The check was skipped because the caller is in snapshot/test mode. */
|
|
47
|
+
SNAPSHOT_MODE: "snapshot_mode",
|
|
48
|
+
/** No Pi-managed async context was detected; fail closed. */
|
|
49
|
+
REJECTED: "rejected",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Validation
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate that the current execution context is a visible Pi-managed async run.
|
|
58
|
+
*
|
|
59
|
+
* Returns a result object describing whether the check passed, was allowed by
|
|
60
|
+
* config, or was rejected. Callers should treat `rejected` as a hard stop.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} params
|
|
63
|
+
* @param {Record<string, string|undefined>} [params.env] - Environment variables to inspect.
|
|
64
|
+
* @param {boolean} [params.isSnapshotMode] - True when running in snapshot/test input mode.
|
|
65
|
+
* @param {"required"|"allowed"} [params.asyncStartMode] - Settings-driven async-start mode.
|
|
66
|
+
* @returns {{ status: string, reason: string, detectedMarker: string|null }}
|
|
67
|
+
*/
|
|
68
|
+
export function validateAsyncStartContext({
|
|
69
|
+
env = process.env,
|
|
70
|
+
isSnapshotMode = false,
|
|
71
|
+
asyncStartMode = ASYNC_START_MODE.REQUIRED,
|
|
72
|
+
} = {}) {
|
|
73
|
+
// Snapshot/test mode implicitly skips — no real async ownership needed
|
|
74
|
+
if (isSnapshotMode) {
|
|
75
|
+
return {
|
|
76
|
+
status: ASYNC_START_STATUS.SNAPSHOT_MODE,
|
|
77
|
+
reason: "Snapshot/test input mode; async-start check not required.",
|
|
78
|
+
detectedMarker: null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (
|
|
83
|
+
asyncStartMode !== ASYNC_START_MODE.REQUIRED &&
|
|
84
|
+
asyncStartMode !== ASYNC_START_MODE.ALLOWED
|
|
85
|
+
) {
|
|
86
|
+
return {
|
|
87
|
+
status: ASYNC_START_STATUS.REJECTED,
|
|
88
|
+
reason:
|
|
89
|
+
`Unrecognized workflow.asyncStartMode value ${JSON.stringify(asyncStartMode)}. ` +
|
|
90
|
+
`Expected ${ASYNC_START_MODE.REQUIRED} or ${ASYNC_START_MODE.ALLOWED}.`,
|
|
91
|
+
detectedMarker: null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Check for any Pi-managed async context marker
|
|
96
|
+
for (const marker of PI_ASYNC_CONTEXT_MARKERS) {
|
|
97
|
+
const value = env[marker];
|
|
98
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
99
|
+
return {
|
|
100
|
+
status: ASYNC_START_STATUS.VALID,
|
|
101
|
+
reason: `Pi-managed async context detected via ${marker}.`,
|
|
102
|
+
detectedMarker: marker,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (asyncStartMode === ASYNC_START_MODE.ALLOWED) {
|
|
108
|
+
return {
|
|
109
|
+
status: ASYNC_START_STATUS.ALLOWED,
|
|
110
|
+
reason: "Async-start check allowed by workflow.asyncStartMode=allowed.",
|
|
111
|
+
detectedMarker: null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const sessionOnlyMarker =
|
|
116
|
+
(typeof env.PI_SESSION_ID === "string" && env.PI_SESSION_ID.trim().length > 0)
|
|
117
|
+
? "PI_SESSION_ID"
|
|
118
|
+
: ((typeof env.PI_ASYNC_CONTEXT === "string" && env.PI_ASYNC_CONTEXT.trim().length > 0)
|
|
119
|
+
? "PI_ASYNC_CONTEXT"
|
|
120
|
+
: null);
|
|
121
|
+
if (sessionOnlyMarker !== null) {
|
|
122
|
+
return {
|
|
123
|
+
status: ASYNC_START_STATUS.REJECTED,
|
|
124
|
+
reason:
|
|
125
|
+
`Detected ${sessionOnlyMarker}, but GitHub-first async-start requires a visible ` +
|
|
126
|
+
"Pi-managed subagent run id for inspectable startup/resume evidence. " +
|
|
127
|
+
"Set PI_SUBAGENT_RUN_ID to proceed. Any exception must come from repository-maintained workflow policy.",
|
|
128
|
+
detectedMarker: null,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (env.PI_DEV_LOOP_DETACHED === "1") {
|
|
133
|
+
return {
|
|
134
|
+
status: ASYNC_START_STATUS.REJECTED,
|
|
135
|
+
reason:
|
|
136
|
+
"Detected detached local background execution; detached/local fallback is diagnostic-only " +
|
|
137
|
+
"and does not satisfy the async-start contract. Restart via Pi-managed async mode. " +
|
|
138
|
+
"Any relaxed posture must come from repository-maintained workflow policy.",
|
|
139
|
+
detectedMarker: null,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// No marker found — fail closed
|
|
144
|
+
return {
|
|
145
|
+
status: ASYNC_START_STATUS.REJECTED,
|
|
146
|
+
reason:
|
|
147
|
+
"No Pi-managed async context detected. " +
|
|
148
|
+
"The dev-loop must run within a visible Pi async subagent session, " +
|
|
149
|
+
"not as a detached local process. " +
|
|
150
|
+
`Set ${PI_ASYNC_CONTEXT_MARKERS[0]} to proceed. Repository-maintained workflow policy controls any exceptions.`,
|
|
151
|
+
detectedMarker: null,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Build a fail-closed error payload for rejected async-start validation.
|
|
157
|
+
*
|
|
158
|
+
* This returns the same JSON error shape used by the CLI scripts so callers
|
|
159
|
+
* can emit it on stderr and exit non-zero.
|
|
160
|
+
*
|
|
161
|
+
* @param {{ status: string, reason: string }} validationResult
|
|
162
|
+
* @returns {{ ok: false, error: string, asyncStartContract: string }}
|
|
163
|
+
*/
|
|
164
|
+
export function buildAsyncStartRejection(validationResult) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
error: validationResult.reason,
|
|
168
|
+
asyncStartContract: "rejected",
|
|
169
|
+
};
|
|
170
|
+
}
|