@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.
@@ -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
- const conflicts = normalizeIdSet(d.conflictingCriteria, "decision.conflictingCriteria");
373
- if (conflicts.size === 0) {
374
- throw new Error(`SPEC-AUTHORITY-CONFLICT-EVIDENCE: ${d.outcome} decision requires non-empty conflictingCriteria (explicit conflict evidence; fail closed)`);
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, paginateNodes, extractStatus } from "./projects-access.mjs";
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, repoName] = repo.split("/");
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
- // Fetch the full board item list ONCE (paginated, position order) and resolve
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
+ }