@dev-loops/core 1.0.0-rc.7 → 1.0.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.
@@ -3,6 +3,11 @@ import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
3
3
  import { evaluateUiE2eScoping } from "./ui-e2e-scoping.mjs";
4
4
  import { evaluateUiDesignerReviewScoping } from "./ui-designer-review-scoping.mjs";
5
5
  import { trimmedOrNull } from "./normalize.mjs";
6
+ import {
7
+ MISSING_AC_CHECKLIST_FINDING,
8
+ MISSING_DOD_CHECKLIST_FINDING,
9
+ MISSING_EXPLICIT_NON_GOALS_FINDING,
10
+ } from "./issue-refinement-artifact.mjs";
6
11
 
7
12
  export const PR_CHECKPOINT = Object.freeze({
8
13
  DRAFT_REVIEW: "draft_review",
@@ -241,15 +246,35 @@ function normalizeRefinementArtifactStatus(value) {
241
246
  // their own validation-failure reason from the detector; that reason must
242
247
  // replace the "linked issue" wording, which does not apply when the PR is the
243
248
  // spec-of-record and no linked issue was ever expected.
249
+ // #1877 full-matrix vocabulary for the draft-gate blocked reason: which
250
+ // matrix arm is missing per finding — the SAME finding taxonomy the enqueue
251
+ // gate's guidance (decideEnqueueRefinementGate) and the detector
252
+ // (detectIssueRefinementArtifact) use, so the draft gate (the unconditional
253
+ // backstop for that floor) cannot drift from it. An AC-only or DoD-only
254
+ // linked issue is a matrix miss, not "no artifact" — the guidance must name
255
+ // the actually-missing arm so the fix is not misdirected.
256
+ const REFINEMENT_MISSING_ARM_BY_FINDING = Object.freeze({
257
+ [MISSING_DOD_CHECKLIST_FINDING]: "a Definition of done checklist (mapped to the acceptance criteria)",
258
+ [MISSING_AC_CHECKLIST_FINDING]: "an Acceptance criteria checklist (for the DoD items to map to)",
259
+ [MISSING_EXPLICIT_NON_GOALS_FINDING]: "an explicit Non-goals section",
260
+ });
261
+
244
262
  function formatRefinementBlockedReason(linkedIssue, status, refinementArtifact) {
245
263
  const specSource = refinementArtifact?.specSource;
246
264
  if (specSource != null && specSource !== REFINEMENT_ARTIFACT_SPEC_SOURCE.LINKED_ISSUE && typeof refinementArtifact?.reason === "string" && refinementArtifact.reason.length > 0) {
247
265
  return `The draft gate cannot complete: ${refinementArtifact.reason} finding=${REFINEMENT_ARTIFACT_FINDING}`;
248
266
  }
267
+ const finding = typeof refinementArtifact?.finding === "string" && refinementArtifact.finding.length > 0
268
+ ? refinementArtifact.finding
269
+ : REFINEMENT_ARTIFACT_FINDING;
270
+ const missingArm = REFINEMENT_MISSING_ARM_BY_FINDING[finding];
271
+ if (missingArm !== undefined) {
272
+ return `Linked issue #${linkedIssue} has an incomplete refinement matrix — it is missing ${missingArm}. Add it to the issue body to complete the full AC/DoD/Non-goals matrix, then re-open the draft PR. finding=${finding}`;
273
+ }
249
274
  if (linkedIssue !== null && Number.isInteger(linkedIssue)) {
250
- return `Linked issue #${linkedIssue} has no refinement artifact (Acceptance criteria / DoD / linked refinement doc). Run refinement first, add ACs/DoD to the issue, then re-open the draft PR. finding=${REFINEMENT_ARTIFACT_FINDING}`;
275
+ return `Linked issue #${linkedIssue} has no refinement artifact (no Acceptance criteria checklist, DoD checklist, or resolvable linked refinement doc). Refine the issue to the full AC/DoD/Non-goals matrix or link a refinement doc (tmp/refinement/*.md), a complete artifact on its own — then re-open the draft PR. finding=${REFINEMENT_ARTIFACT_FINDING}`;
251
276
  }
252
- return `The draft gate cannot complete: the linked issue has no detectable refinement artifact (Acceptance criteria / DoD / linked refinement doc). finding=${REFINEMENT_ARTIFACT_FINDING}`;
277
+ return `The draft gate cannot complete: the linked issue has no detectable refinement artifact (no Acceptance criteria checklist, DoD checklist, or resolvable linked refinement doc). finding=${REFINEMENT_ARTIFACT_FINDING}`;
253
278
  }
254
279
 
255
280
  // #1472: describes the CI state a round-cap-reached fallback branch actually
@@ -2,7 +2,9 @@ import {
2
2
  evaluateRetrospectiveGate,
3
3
  normalizeRetrospectiveCheckpointState,
4
4
  normalizeCheckpointCycleIdentity,
5
+ normalizeRetroProvenance,
5
6
  resolveCheckpointStateFromArtifact,
7
+ RETROSPECTIVE_PROVENANCE,
6
8
  } from "./retrospective-checkpoint.mjs";
7
9
  import {
8
10
  EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
@@ -43,7 +45,9 @@ export * from "./public-dev-loop-routing-contract.mjs";
43
45
  // package export (see skills/docs/retrospective-checkpoint-contract.md).
44
46
  export {
45
47
  normalizeCheckpointCycleIdentity,
48
+ normalizeRetroProvenance,
46
49
  resolveCheckpointStateFromArtifact,
50
+ RETROSPECTIVE_PROVENANCE,
47
51
  };
48
52
 
49
53
  const COPILOT_ISSUE_ASSIGNEE = "copilot-swe-agent";
@@ -172,9 +172,8 @@ function readDevloopsSettings(repoRoot) {
172
172
  try {
173
173
  const raw = readFileSync(base + ext, "utf8");
174
174
  const settings = ext === ".json" ? JSON.parse(raw) : parseYaml(raw);
175
- // `tracker` (issue #1408, the tracker-agnostic seam) is surfaced
176
- // alongside `queue` so loadBoardConfig can prefer tracker.board over
177
- // the deprecated queue.board without a second file read.
175
+ // `tracker` (issue #1408, the tracker-agnostic seam) is surfaced so
176
+ // loadBoardConfig can read tracker.board directly.
178
177
  return { settings: settings?.queue ?? null, tracker: settings?.tracker ?? null };
179
178
  } catch (err) {
180
179
  if (err?.code === "ENOENT") {
@@ -204,18 +203,15 @@ function boardSelector(board) {
204
203
  }
205
204
 
206
205
  export function loadBoardConfig(repoRoot) {
207
- const { settings: queue, tracker, error } = readDevloopsSettings(repoRoot);
206
+ const { tracker, error } = readDevloopsSettings(repoRoot);
208
207
  if (error) {
209
208
  return { enabled: false, reason: `config read/parse error: ${error}` };
210
209
  }
211
- // tracker.board (canonical) takes priority over the deprecated queue.board
212
- // (issue #1408) — see resolveTrackerBoard in ../config/config.mjs for the
213
- // equivalent resolution against the validated, loaded config.
210
+ // tracker.board (canonical board key, issue #1408) see resolveTrackerBoard
211
+ // in ../config/config.mjs for the equivalent resolution against the
212
+ // validated, loaded config.
214
213
  const trackerBoard = boardSelector(tracker?.board);
215
214
  if (trackerBoard) return trackerBoard;
216
- if (!queue) return { enabled: false };
217
- const queueBoard = boardSelector(queue.board);
218
- if (queueBoard) return queueBoard;
219
215
  return { enabled: false };
220
216
  }
221
217
 
@@ -55,6 +55,54 @@ export const RETROSPECTIVE_QUALIFYING_GATES = Object.freeze([
55
55
  "issue_intake",
56
56
  ]);
57
57
 
58
+ /**
59
+ * Provenance context values for a recorded retrospective (issue #1870).
60
+ *
61
+ * A retrospective MUST be produced by a FRESH-CONTEXT, independent dispatch
62
+ * (analogous to a gate reviewer) seeded with the cycle's full agent/subagent
63
+ * tool-call/action/result record — never written inline by the same working
64
+ * context that did the work. A self-authored retro reflects the working
65
+ * agent's own blind spots back at it; it validates consistency, not
66
+ * conformance, so an inline retro fails the checkpoint.
67
+ */
68
+ export const RETROSPECTIVE_PROVENANCE = Object.freeze({
69
+ /** Dispatched as a fresh, independent context (the only accepting value). */
70
+ CONTEXT_FRESH: "fresh",
71
+ /** Self-authored by the working context — rejected, fails closed. */
72
+ CONTEXT_INLINE: "inline",
73
+ /** The retro was seeded with the full agent/subagent tool-call record. */
74
+ SEEDED_FROM_RECORD: "agent_tool_call_record",
75
+ });
76
+
77
+ /**
78
+ * Normalizes a retrospective provenance record from a durable checkpoint
79
+ * artifact. Returns the normalized provenance only when it pins a valid
80
+ * fresh-context pass over the full tool-call record:
81
+ * - `context` must normalize to "fresh" (trimmed, case-insensitive; an
82
+ * "inline"/self-authored retro is rejected — it fails closed, never accepted)
83
+ * - `seededFrom` must be exactly "agent_tool_call_record" (the retro audited
84
+ * the cycle's actual behavior, not a summary)
85
+ * - `recordSource` must be a non-blank string (the transcript/journal path
86
+ * the retro was seeded with)
87
+ *
88
+ * Returns null for anything else — absent, malformed, inline, or partial.
89
+ *
90
+ * @param {unknown} value
91
+ * @returns {{context: "fresh", seededFrom: "agent_tool_call_record", recordSource: string}|null}
92
+ */
93
+ export function normalizeRetroProvenance(value) {
94
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
95
+ return null;
96
+ }
97
+ const context = typeof value.context === "string" ? value.context.trim().toLowerCase() : "";
98
+ const seededFrom = typeof value.seededFrom === "string" ? value.seededFrom.trim().toLowerCase() : "";
99
+ const recordSource = typeof value.recordSource === "string" ? value.recordSource.trim() : "";
100
+ if (context !== RETROSPECTIVE_PROVENANCE.CONTEXT_FRESH || seededFrom !== RETROSPECTIVE_PROVENANCE.SEEDED_FROM_RECORD || recordSource.length === 0) {
101
+ return null;
102
+ }
103
+ return { context: RETROSPECTIVE_PROVENANCE.CONTEXT_FRESH, seededFrom: RETROSPECTIVE_PROVENANCE.SEEDED_FROM_RECORD, recordSource };
104
+ }
105
+
58
106
  /**
59
107
  * Normalizes an external retrospective checkpoint-state input to one of the
60
108
  * stable RETROSPECTIVE_CHECKPOINT_STATE values. Returns null when the value is
@@ -149,7 +197,17 @@ export function resolveCheckpointStateFromArtifact(artifact, { hasNewerMergeSinc
149
197
  return hasNewerMergeSinceCheckpoint ? RETROSPECTIVE_CHECKPOINT_STATE.MISSING : RETROSPECTIVE_CHECKPOINT_STATE.SKIPPED;
150
198
  }
151
199
  if (rawState === "complete") {
152
- return hasNewerMergeSinceCheckpoint ? RETROSPECTIVE_CHECKPOINT_STATE.MISSING : RETROSPECTIVE_CHECKPOINT_STATE.COMPLETE;
200
+ if (hasNewerMergeSinceCheckpoint) {
201
+ return RETROSPECTIVE_CHECKPOINT_STATE.MISSING;
202
+ }
203
+ // Fresh-context provenance is mandatory (issue #1870): a `complete` record
204
+ // without provenance that pins a fresh-context pass over the full
205
+ // tool-call record — including legacy inline/self-authored retros — fails
206
+ // closed to MISSING. The old inline self-review path is disallowed.
207
+ if (normalizeRetroProvenance(artifact.provenance) === null) {
208
+ return RETROSPECTIVE_CHECKPOINT_STATE.MISSING;
209
+ }
210
+ return RETROSPECTIVE_CHECKPOINT_STATE.COMPLETE;
153
211
  }
154
212
  // Malformed/unrecognized durable state — fail closed.
155
213
  return RETROSPECTIVE_CHECKPOINT_STATE.MISSING;
@@ -369,7 +369,7 @@ async function main(args, { env = process.env, runChild, cwd = null } = {}) {
369
369
  { code: "CONFIG_ERROR" },
370
370
  );
371
371
  }
372
- const decision = await runPickupRefinementGate({ issueNumber, repo, env, runChild: child, auto: false });
372
+ const decision = await runPickupRefinementGate({ issueNumber, repo, env, runChild: child, auto: false, repoRoot: cwd });
373
373
  refinement = { refined: decision.action === "enqueue" };
374
374
  }
375
375
  }
@@ -6,9 +6,9 @@ import { parse as parseYaml } from "yaml";
6
6
  // resolution used by ensure-queue-board.mjs. Returns { project }, { title },
7
7
  // and/or { olderThanDays } when configured; never throws on a missing/bad file.
8
8
  //
9
- // `tracker.board` (issue #1408, the tracker-agnostic seam) takes priority over
10
- // the deprecated `queue.board` — same precedence as loadBoardConfig in
11
- // ../loop/queue-board-sync.mjs and resolveTrackerBoard in ../config/config.mjs.
9
+ // The board resolves from `tracker.board` (issue #1408, the tracker-agnostic
10
+ // seam) — same source as loadBoardConfig in ../loop/queue-board-sync.mjs and
11
+ // resolveTrackerBoard in ../config/config.mjs.
12
12
  function resolveSettings(cwd) {
13
13
  const basePath = path.join(cwd, ".devloops");
14
14
  const extensions = ["", ".yaml", ".yml", ".json"];
@@ -18,7 +18,7 @@ function resolveSettings(cwd) {
18
18
  const settings = ext === ".json" ? JSON.parse(raw) : parseYaml(raw);
19
19
  const queue = settings?.queue;
20
20
  const out = {};
21
- const board = settings?.tracker?.board ?? queue?.board;
21
+ const board = settings?.tracker?.board;
22
22
  if (board && typeof board === "object") {
23
23
  if (typeof board.number === "number" && Number.isInteger(board.number) && board.number > 0) {
24
24
  out.project = board.number;
@@ -139,7 +139,7 @@ function resolveProjectSelector(args) {
139
139
  : null;
140
140
  if (!projectRef && !projectTitle) {
141
141
  throw Object.assign(
142
- new Error("--project is required (or set tracker.board — or the deprecated queue.board — number / title in .devloops)"),
142
+ new Error("--project is required (or set tracker.board number / title in .devloops)"),
143
143
  { code: "INVALID_PROJECT" },
144
144
  );
145
145
  }
@@ -178,7 +178,7 @@ function findProject(projects, { projectRef, projectTitle }, owner) {
178
178
  }
179
179
 
180
180
  // Apply .devloops board settings when --project was not passed. Precedence:
181
- // explicit --project flag > queue.board.number/queue.board.title. Mutates args.
181
+ // explicit --project flag > tracker.board.number/tracker.board.title. Mutates args.
182
182
  function applyDevloopsBoard(args, cwd) {
183
183
  if (args.project === undefined) {
184
184
  const settings = resolveSettings(cwd);