@dev-loops/core 1.0.3 → 1.0.4-pre.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 +1 -1
- package/src/analysis/change-classifier.mjs +49 -28
- package/src/analysis/diff-analyzer.mjs +127 -31
- package/src/claude/asset-generation.mjs +36 -1
- package/src/claude/hook-decisions.mjs +117 -18
- package/src/config/config.mjs +198 -86
- package/src/config/extension-defaults.yaml +66 -6
- package/src/github/copilot-helpers.mjs +85 -20
- package/src/github/gh.mjs +14 -1
- package/src/github/review-threads.mjs +6 -0
- package/src/loop/bash-command-classify.mjs +251 -14
- package/src/loop/copilot-ci-status.mjs +116 -6
- package/src/loop/copilot-loop-state.mjs +38 -2
- package/src/loop/finding-cluster.mjs +30 -11
- package/src/loop/gate-carry-forward.mjs +39 -6
- package/src/loop/gate-fanin.mjs +58 -3
- package/src/loop/issue-refinement-artifact.mjs +117 -9
- package/src/loop/main-checkout-ff.mjs +169 -17
- package/src/loop/merge-approval.mjs +186 -5
- package/src/loop/pr-gate-coordination.mjs +339 -67
- package/src/loop/run-inspection.mjs +6 -0
- package/src/loop/spec-authority.mjs +40 -6
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/projects/move-queue-item.mjs +5 -79
- package/src/projects/projects-access.mjs +124 -0
|
@@ -555,6 +555,12 @@ export function composeRunInspectionSnapshot({
|
|
|
555
555
|
if (lifecyclePhase === null) {
|
|
556
556
|
// Fallback: derive from available PR facts
|
|
557
557
|
const loopIter = loopIterations ?? {};
|
|
558
|
+
// Deferring the loop-iteration fan-out cannot change the phase: every copilot
|
|
559
|
+
// state maps to one (COPILOT_INNER_STATE_MAP is total over STATE), so whenever
|
|
560
|
+
// copilot evidence is present the phase is already resolved above and this
|
|
561
|
+
// fallback never runs. It runs only when that evidence is ABSENT, where there
|
|
562
|
+
// is no thread count to consult from any source — so there is nothing here to
|
|
563
|
+
// reconcile between a deferred and a full inspection.
|
|
558
564
|
const hasUnresolvedThreads = typeof loopIter.unresolvedReviewThreads === "number"
|
|
559
565
|
&& loopIter.unresolvedReviewThreads > 0;
|
|
560
566
|
const copilotState = copilotLiveOk && copilotEvidence !== null
|
|
@@ -98,6 +98,17 @@ export const SPEC_AUTHORITY_OUTCOME_VALUES = Object.freeze(
|
|
|
98
98
|
* three resolve autonomously. Exported so no consumer re-hardcodes the set. */
|
|
99
99
|
export const HUMAN_SPEC_DECISION_OUTCOME = SPEC_AUTHORITY_OUTCOMES.SPEC_CANNOT_DECIDE;
|
|
100
100
|
|
|
101
|
+
/** The two outcomes that REQUIRE explicit conflict evidence: a non-empty
|
|
102
|
+
* `conflictingCriteria` array of criterion ids (SPEC-AUTHORITY-CONFLICT-EVIDENCE).
|
|
103
|
+
* The enforcer and the divergence guard bind to this set directly; the guard
|
|
104
|
+
* also pins the producer contract prose (`agents/judge.agent.md`) to it, so
|
|
105
|
+
* producer and enforcer cannot silently diverge. SPEC-AUTHORITY-CONFLICT-EVIDENCE
|
|
106
|
+
* in this module is the governing rule. */
|
|
107
|
+
export const SPEC_AUTHORITY_CONFLICT_OUTCOMES = Object.freeze([
|
|
108
|
+
SPEC_AUTHORITY_OUTCOMES.FINDING_CONFLICTS,
|
|
109
|
+
SPEC_AUTHORITY_OUTCOMES.REMEDIATION_CONFLICTS,
|
|
110
|
+
]);
|
|
111
|
+
|
|
101
112
|
/**
|
|
102
113
|
* Does an outcome require the loop to stop at the human-spec-decision state?
|
|
103
114
|
* Only `spec_cannot_decide` does — a finding/remediation conflict alone never
|
|
@@ -364,15 +375,17 @@ export function validateSpecAuthorityDecision(decision, { specDigest, headSha, c
|
|
|
364
375
|
// The two conflict outcomes require explicit conflict evidence: a non-empty
|
|
365
376
|
// conflictingCriteria drawn from the spec. Autonomous rejection is only
|
|
366
377
|
// legitimate when it names what the finding/remedy conflicts with.
|
|
367
|
-
const isConflict =
|
|
368
|
-
d.outcome === SPEC_AUTHORITY_OUTCOMES.FINDING_CONFLICTS ||
|
|
369
|
-
d.outcome === SPEC_AUTHORITY_OUTCOMES.REMEDIATION_CONFLICTS;
|
|
378
|
+
const isConflict = SPEC_AUTHORITY_CONFLICT_OUTCOMES.includes(d.outcome);
|
|
370
379
|
let conflictingCriteria = [];
|
|
371
380
|
if (isConflict) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
381
|
+
// Absent, non-array, and empty all mean "no conflict evidence". Route them
|
|
382
|
+
// to the named rule (not the generic id-set shape error) so a human hitting
|
|
383
|
+
// this is pointed at the fix — populate the field — rather than a hand-edit;
|
|
384
|
+
// naming the criteria in `rationale` prose alone does not satisfy it.
|
|
385
|
+
if (!Array.isArray(d.conflictingCriteria) || d.conflictingCriteria.length === 0) {
|
|
386
|
+
throw new Error(`SPEC-AUTHORITY-CONFLICT-EVIDENCE: ${d.outcome} decision requires a non-empty conflictingCriteria array of criterion ids (explicit conflict evidence; naming them in rationale prose alone does not satisfy it; fail closed)`);
|
|
375
387
|
}
|
|
388
|
+
const conflicts = normalizeIdSet(d.conflictingCriteria, "decision.conflictingCriteria");
|
|
376
389
|
const unknownConflicts = [...conflicts].filter((id) => !fullCriteria.has(id));
|
|
377
390
|
if (unknownConflicts.length > 0) {
|
|
378
391
|
throw new Error(`spec-authority decision.conflictingCriteria names unknown criterion id(s): ${unknownConflicts.join(", ")}`);
|
|
@@ -485,6 +498,27 @@ export function validateSpecAuthorityVerdict(verdict, { findingsCount, criterion
|
|
|
485
498
|
};
|
|
486
499
|
}
|
|
487
500
|
|
|
501
|
+
/**
|
|
502
|
+
* Dispose every spec-authority `finding_conflicts` finding `reject`, whatever
|
|
503
|
+
* its relevance disposition. Mutates the judge-enriched findings in place; the
|
|
504
|
+
* judge pass and the durable findings-log writer share it so the ledger's
|
|
505
|
+
* `judgeDisposition` matches what the judge pass enforces.
|
|
506
|
+
*
|
|
507
|
+
* @param {object[]} findings — judge-enriched findings, indexed as the verdict
|
|
508
|
+
* @param {Iterable<number>} conflictIndices — the `finding_conflicts` indexes
|
|
509
|
+
* @returns {object[]} the same `findings` array
|
|
510
|
+
*/
|
|
511
|
+
export function rejectFindingConflicts(findings, conflictIndices) {
|
|
512
|
+
const rejected = new Set(conflictIndices);
|
|
513
|
+
for (const [i, f] of findings.entries()) {
|
|
514
|
+
if (rejected.has(i) && f.judgeDisposition !== "reject") {
|
|
515
|
+
f.judgeRationale = `spec-authority finding_conflicts: rejected against the spec (was relevance-${f.judgeDisposition}) — ${f.judgeRationale ?? ""}`.trim();
|
|
516
|
+
f.judgeDisposition = "reject";
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return findings;
|
|
520
|
+
}
|
|
521
|
+
|
|
488
522
|
/**
|
|
489
523
|
* Resolve which prior criterion approvals survive a revision change. This is the
|
|
490
524
|
* one authority for both invalidation rules:
|
|
@@ -40,6 +40,7 @@ export const REGISTERED_ARTIFACT_PATHS = Object.freeze([
|
|
|
40
40
|
"docs/presentations/dev-loops-deep-dive.html",
|
|
41
41
|
"docs/presentations/how-dev-loops-decided-itself.html",
|
|
42
42
|
"docs/presentations/state-graph-surface.html",
|
|
43
|
+
"docs/presentations/finding-the-flow.html",
|
|
43
44
|
"docs/articles/introducing-dev-loops.html",
|
|
44
45
|
"docs/articles/dev-loops-deep-dive.html",
|
|
45
46
|
"docs/articles/how-dev-loops-decided-itself.html",
|
|
@@ -3,7 +3,7 @@ import { runPickupRefinementGate } from "../loop/issue-refinement-artifact.mjs";
|
|
|
3
3
|
import { loadStateColumnMap, LOGICAL_COLUMN } from "../loop/queue-board-sync.mjs";
|
|
4
4
|
import { resolveProjectSelector, findProject, parseItemRef } from "./resolve-project.mjs";
|
|
5
5
|
import { ghGraphql, resolveOwner } from "../github/gh.mjs";
|
|
6
|
-
import { validateProjectsRepo, discoverProjects, listProjectFields,
|
|
6
|
+
import { validateProjectsRepo, discoverProjects, listProjectFields, extractStatus, resolveProjectItem } from "./projects-access.mjs";
|
|
7
7
|
|
|
8
8
|
// ── Validation ───────────────────────────────────────────────────────────
|
|
9
9
|
|
|
@@ -11,33 +11,6 @@ const validateRepo = validateProjectsRepo;
|
|
|
11
11
|
|
|
12
12
|
// ── GraphQL fragments ────────────────────────────────────────────────────
|
|
13
13
|
|
|
14
|
-
const GET_PROJECT_ITEMS_BY_CONTENT = [
|
|
15
|
-
"query($projectId:ID!, $after:String) {",
|
|
16
|
-
" node(id:$projectId) {",
|
|
17
|
-
" ... on ProjectV2 {",
|
|
18
|
-
" items(first:100, after:$after, orderBy:{field:POSITION, direction:ASC}) {",
|
|
19
|
-
" pageInfo { hasNextPage endCursor }",
|
|
20
|
-
" nodes {",
|
|
21
|
-
" id",
|
|
22
|
-
" fieldValues(first:20) {",
|
|
23
|
-
" nodes {",
|
|
24
|
-
" ... on ProjectV2ItemFieldSingleSelectValue {",
|
|
25
|
-
" field { ... on ProjectV2SingleSelectField { id name } }",
|
|
26
|
-
" name",
|
|
27
|
-
" }",
|
|
28
|
-
" }",
|
|
29
|
-
" }",
|
|
30
|
-
" content {",
|
|
31
|
-
" ... on Issue { __typename number repository { nameWithOwner } }",
|
|
32
|
-
" ... on PullRequest { __typename number repository { nameWithOwner } }",
|
|
33
|
-
" }",
|
|
34
|
-
" }",
|
|
35
|
-
" }",
|
|
36
|
-
" }",
|
|
37
|
-
" }",
|
|
38
|
-
"}"
|
|
39
|
-
].join("\n");
|
|
40
|
-
|
|
41
14
|
const UPDATE_ITEM_FIELD = [
|
|
42
15
|
"mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $optionId:String!) {",
|
|
43
16
|
" updateProjectV2ItemFieldValue(input:{projectId:$projectId, itemId:$itemId, fieldId:$fieldId, value:{singleSelectOptionId:$optionId}}) {",
|
|
@@ -53,19 +26,6 @@ const UPDATE_ITEM_FIELD = [
|
|
|
53
26
|
const listAllProjects = discoverProjects;
|
|
54
27
|
const listAllFields = listProjectFields;
|
|
55
28
|
|
|
56
|
-
// ── Paginated item listing (position order) ──────────────────────────────
|
|
57
|
-
|
|
58
|
-
function fetchAllItems(projectId, env, runChild) {
|
|
59
|
-
return paginateNodes({
|
|
60
|
-
query: GET_PROJECT_ITEMS_BY_CONTENT,
|
|
61
|
-
variables: { projectId },
|
|
62
|
-
selectConnection: (payload) => payload?.data?.node?.items,
|
|
63
|
-
env,
|
|
64
|
-
runChild,
|
|
65
|
-
entity: "items",
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
29
|
const statusOf = extractStatus;
|
|
70
30
|
|
|
71
31
|
// ── Exit code classification ────────────────────────────────────────────
|
|
@@ -84,7 +44,7 @@ function classifyExitCode(err) {
|
|
|
84
44
|
async function main(args, { env = process.env, runChild, cwd = null } = {}) {
|
|
85
45
|
const child = runChild ?? _runChild;
|
|
86
46
|
const repo = validateRepo(args.repo);
|
|
87
|
-
const [owner
|
|
47
|
+
const [owner] = repo.split("/");
|
|
88
48
|
const selector = resolveProjectSelector(args);
|
|
89
49
|
const itemRef = parseItemRef(args.item);
|
|
90
50
|
const toColumn = (args.toColumn ?? "").trim();
|
|
@@ -122,43 +82,9 @@ async function main(args, { env = process.env, runChild, cwd = null } = {}) {
|
|
|
122
82
|
);
|
|
123
83
|
}
|
|
124
84
|
|
|
125
|
-
// 4. Find the item
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
// BOTH ref kinds against it. This reuses the proven pattern from
|
|
129
|
-
// reorder-queue-item / list-queue-items: a node-id ref matches by item.id, a
|
|
130
|
-
// number ref matches by content.number. Both are scoped to the requested repo
|
|
131
|
-
// so a cross-project ref fails closed with ITEM_NOT_FOUND. (The previous code
|
|
132
|
-
// used `ProjectV2.item` — a field that does not exist — for the node-id path,
|
|
133
|
-
// and a single non-paginated `items(first:10)` page for the number path, so it
|
|
134
|
-
// could not find items beyond the first page.)
|
|
135
|
-
const allItems = await fetchAllItems(project.id, env, child);
|
|
136
|
-
|
|
137
|
-
let match;
|
|
138
|
-
if (itemRef.kind === "id") {
|
|
139
|
-
match = allItems.find(
|
|
140
|
-
(it) => it.id === itemRef.value && it.content?.repository?.nameWithOwner === repo,
|
|
141
|
-
);
|
|
142
|
-
if (!match) {
|
|
143
|
-
throw Object.assign(
|
|
144
|
-
new Error(`Item "${itemRef.value}" not found in project "${project.title}" for repo "${repo}"`),
|
|
145
|
-
{ code: "ITEM_NOT_FOUND" },
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
} else {
|
|
149
|
-
match = allItems.find(
|
|
150
|
-
(it) =>
|
|
151
|
-
it.content &&
|
|
152
|
-
it.content.repository?.nameWithOwner === repo &&
|
|
153
|
-
it.content.number === itemRef.value,
|
|
154
|
-
);
|
|
155
|
-
if (!match) {
|
|
156
|
-
throw Object.assign(
|
|
157
|
-
new Error(`Item #${itemRef.value} not found in project "${project.title}" for repo "${repo}"`),
|
|
158
|
-
{ code: "ITEM_NOT_FOUND" },
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
85
|
+
// 4. Find the item from the issue side or by node, never from the board
|
|
86
|
+
// listing: `ProjectV2.items` can lag behind GitHub and omit new items.
|
|
87
|
+
const match = await resolveProjectItem({ projectId: project.id, projectTitle: project.title, repo, itemRef, env, runChild: child });
|
|
162
88
|
|
|
163
89
|
const itemId = match.id;
|
|
164
90
|
const previousColumn = statusOf(match);
|
|
@@ -200,3 +200,127 @@ export function extractStatus(node) {
|
|
|
200
200
|
}
|
|
201
201
|
return null;
|
|
202
202
|
}
|
|
203
|
+
|
|
204
|
+
// ── Single item resolution ─────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
// Item projection shared by both lookups. It matches the board listing node
|
|
207
|
+
// shape (id, fieldValues, content) so `extractStatus` works on the result.
|
|
208
|
+
const ITEM_FIELDS = [
|
|
209
|
+
"id isArchived project { id }",
|
|
210
|
+
"fieldValues(first:20) {",
|
|
211
|
+
" nodes {",
|
|
212
|
+
" ... on ProjectV2ItemFieldSingleSelectValue {",
|
|
213
|
+
" field { ... on ProjectV2SingleSelectField { id name } }",
|
|
214
|
+
" name",
|
|
215
|
+
" }",
|
|
216
|
+
" }",
|
|
217
|
+
"}",
|
|
218
|
+
"content {",
|
|
219
|
+
" ... on Issue { __typename number repository { nameWithOwner } }",
|
|
220
|
+
" ... on PullRequest { __typename number repository { nameWithOwner } }",
|
|
221
|
+
"}",
|
|
222
|
+
].join("\n");
|
|
223
|
+
|
|
224
|
+
const GET_ITEMS_BY_CONTENT_NUMBER = [
|
|
225
|
+
"query($owner:String!, $name:String!, $number:Int!, $after:String) {",
|
|
226
|
+
" repository(owner:$owner, name:$name) {",
|
|
227
|
+
" issueOrPullRequest(number:$number) {",
|
|
228
|
+
` ... on Issue { projectItems(first:100, after:$after, includeArchived:false) { pageInfo { hasNextPage endCursor } nodes { ${ITEM_FIELDS} } } }`,
|
|
229
|
+
` ... on PullRequest { projectItems(first:100, after:$after, includeArchived:false) { pageInfo { hasNextPage endCursor } nodes { ${ITEM_FIELDS} } } }`,
|
|
230
|
+
" }",
|
|
231
|
+
" }",
|
|
232
|
+
"}",
|
|
233
|
+
].join("\n");
|
|
234
|
+
|
|
235
|
+
const GET_ITEM_BY_ID = [
|
|
236
|
+
"query($id:ID!) {",
|
|
237
|
+
` node(id:$id) { ... on ProjectV2Item { ${ITEM_FIELDS} } }`,
|
|
238
|
+
"}",
|
|
239
|
+
].join("\n");
|
|
240
|
+
|
|
241
|
+
function itemNotFound(message) {
|
|
242
|
+
return Object.assign(new Error(message), { code: "ITEM_NOT_FOUND" });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Throw GRAPHQL_ERROR for any GraphQL error in `payload` other than NOT_FOUND.
|
|
247
|
+
* Use it on a `ghGraphql(..., { allowErrors: true })` payload where NOT_FOUND
|
|
248
|
+
* means "no such entity" and every other error must keep its message.
|
|
249
|
+
*/
|
|
250
|
+
export function assertOnlyNotFoundErrors(payload) {
|
|
251
|
+
const other = (payload?.errors ?? []).filter((e) => e?.type !== "NOT_FOUND");
|
|
252
|
+
if (other.length > 0) {
|
|
253
|
+
throw Object.assign(
|
|
254
|
+
new Error(`GraphQL errors: ${other.map((e) => e.message).join("; ")}`),
|
|
255
|
+
{ code: "GRAPHQL_ERROR" },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Resolve one project item without the whole-board `ProjectV2.items` listing,
|
|
262
|
+
* which can lag behind GitHub by hours and omit newly added items.
|
|
263
|
+
*
|
|
264
|
+
* - number ref: read the issue or PR's own `projectItems` and pick the
|
|
265
|
+
* unarchived item on `projectId`.
|
|
266
|
+
* - id ref: look the item node up directly, then verify that it belongs to
|
|
267
|
+
* `projectId` and that its content is in `repo`.
|
|
268
|
+
*
|
|
269
|
+
* Every miss or mismatch fails closed with code ITEM_NOT_FOUND. Returns a node
|
|
270
|
+
* in the listing shape: `{ id, isArchived, project, fieldValues, content }`.
|
|
271
|
+
*
|
|
272
|
+
* @param {object} opts
|
|
273
|
+
* @param {string} opts.projectId
|
|
274
|
+
* @param {string} [opts.projectTitle] shown in ITEM_NOT_FOUND messages when present
|
|
275
|
+
* @param {string} opts.repo validated `owner/name`
|
|
276
|
+
* @param {{kind:"number"|"id", value:number|string}} opts.itemRef from parseItemRef
|
|
277
|
+
* @param {object} opts.env
|
|
278
|
+
* @param {Function} opts.runChild
|
|
279
|
+
*/
|
|
280
|
+
export async function resolveProjectItem({ projectId, projectTitle, repo, itemRef, env, runChild }) {
|
|
281
|
+
const projectLabel = projectTitle ?? projectId;
|
|
282
|
+
if (itemRef.kind === "number") {
|
|
283
|
+
const [owner, name] = repo.split("/");
|
|
284
|
+
let after = null;
|
|
285
|
+
while (true) {
|
|
286
|
+
const vars = { owner, name, number: itemRef.value };
|
|
287
|
+
if (after) vars.after = after;
|
|
288
|
+
const payload = await ghGraphql(GET_ITEMS_BY_CONTENT_NUMBER, vars, env, runChild, { allowErrors: true });
|
|
289
|
+
const connection = payload?.data?.repository?.issueOrPullRequest?.projectItems;
|
|
290
|
+
const match = (connection?.nodes ?? []).find((n) => n && n.project?.id === projectId && !n.isArchived);
|
|
291
|
+
// A partial response can carry errors for other boards the token cannot
|
|
292
|
+
// read (e.g. FORBIDDEN). A match on the configured board wins; the error
|
|
293
|
+
// is raised only when no page holds a match.
|
|
294
|
+
if (match) return match;
|
|
295
|
+
const pageInfo = connection?.pageInfo ?? {};
|
|
296
|
+
if (!pageInfo.hasNextPage) {
|
|
297
|
+
assertOnlyNotFoundErrors(payload);
|
|
298
|
+
throw itemNotFound(`Item #${itemRef.value} not found in project "${projectLabel}" for repo "${repo}"`);
|
|
299
|
+
}
|
|
300
|
+
if (!pageInfo.endCursor) {
|
|
301
|
+
throw Object.assign(
|
|
302
|
+
new Error("Invalid projectItems payload: hasNextPage is true but endCursor is missing"),
|
|
303
|
+
{ code: "GH_API_ERROR" },
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
after = pageInfo.endCursor;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const payload = await ghGraphql(GET_ITEM_BY_ID, { id: itemRef.value }, env, runChild, { allowErrors: true });
|
|
311
|
+
assertOnlyNotFoundErrors(payload);
|
|
312
|
+
const node = payload?.data?.node;
|
|
313
|
+
if (!node?.id || node.isArchived) {
|
|
314
|
+
throw itemNotFound(`Item "${itemRef.value}" not found in project "${projectLabel}" for repo "${repo}"`);
|
|
315
|
+
}
|
|
316
|
+
if (node.project?.id !== projectId) {
|
|
317
|
+
throw itemNotFound(
|
|
318
|
+
`Item "${itemRef.value}" belongs to project "${node.project?.id ?? "(unknown)"}", not "${projectId}"`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
const itemRepo = node.content?.repository?.nameWithOwner ?? null;
|
|
322
|
+
if (itemRepo !== repo) {
|
|
323
|
+
throw itemNotFound(`Item "${itemRef.value}" is for repo "${itemRepo ?? "(none)"}", not "${repo}"`);
|
|
324
|
+
}
|
|
325
|
+
return node;
|
|
326
|
+
}
|