@dev-loops/core 1.0.0-rc.7 → 1.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "1.0.0-rc.7",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -53,6 +53,7 @@
53
53
  "./loop/refinement-grill-state": "./src/loop/refinement-grill-state.mjs",
54
54
  "./loop/queue-board-ordering": "./src/loop/queue-board-ordering.mjs",
55
55
  "./loop/default-branch-guard": "./src/loop/default-branch-guard.mjs",
56
+ "./loop/commit-msg-guard": "./src/loop/commit-msg-guard.mjs",
56
57
  "./loop/queue-board-sync": "./src/loop/queue-board-sync.mjs",
57
58
  "./loop/queue-driver": "./src/loop/queue-driver.mjs",
58
59
  "./loop/queue-membership": "./src/loop/queue-membership.mjs",
@@ -0,0 +1,168 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Enforces the commit-message contract AT COMMIT TIME (issue #1869): the
6
+ * attribution trailers, the no-bare-#N rule, and the conventional-commit
7
+ * subject form were previously prose-only — nothing checked them, so a
8
+ * non-compliant commit landed silently. Installed alongside the
9
+ * default-branch guard (see default-branch-guard.mjs), through the same
10
+ * ensure-worktree provisioning path, so it rides into every worktree too.
11
+ *
12
+ * The rendered hook is a single self-contained Node script (ESM: this repo's
13
+ * root package.json is `"type": "module"`, and Node resolves an
14
+ * extensionless direct-run script's module type by walking up for the
15
+ * nearest package.json — verified empirically, not merely assumed). Keeping
16
+ * the ENTIRE check inline in the rendered script (rather than requiring a
17
+ * sibling file back into the checkout) means the installed hook keeps
18
+ * working even if the checkout that installed it is later removed or moved
19
+ * — the same self-containment default-branch-guard's hooks rely on.
20
+ */
21
+ export const COMMIT_MSG_GUARD_MARKER = "dev-loops:commit-msg-guard";
22
+ export const COMMIT_MSG_WAIVER_MARKER = `${COMMIT_MSG_GUARD_MARKER}:allow`;
23
+
24
+ // Ownership check mirrors default-branch-guard's: the marker must be its own
25
+ // line (a `//` comment, since the rendered hook is JS), not merely mentioned,
26
+ // so a foreign hook that references us in prose is still left untouched.
27
+ const GUARD_MARKER_LINE = new RegExp(`^// ${COMMIT_MSG_GUARD_MARKER}$`, "mu");
28
+
29
+ /**
30
+ * Renders the commit-msg hook as a standalone, runnable Node script. The
31
+ * validation logic below is the ONLY copy of it — there is no separate JS
32
+ * implementation this must stay in sync with, exactly like renderGuardHook's
33
+ * shell body has none either. Tests exercise it by actually running it (see
34
+ * commit-msg-guard.test.mjs), the same way default-branch-guard.test.mjs
35
+ * drives real git rather than asserting on rendered text.
36
+ *
37
+ * String.raw, not a plain template literal: the generated script is full of
38
+ * regex backslash escapes (\s, \d, \b, \.) that a normal template literal
39
+ * would silently strip (an unrecognized string escape drops its backslash),
40
+ * corrupting every regex in the installed hook. String.raw keeps every
41
+ * backslash literal while still substituting the ${...} marker constants.
42
+ * The generated script deliberately uses NO template literals of its own
43
+ * (string concatenation instead) — a literal backtick would otherwise close
44
+ * THIS OUTER template early.
45
+ */
46
+ export function renderCommitMsgGuardHook() {
47
+ return String.raw`#!/usr/bin/env node
48
+ // ${COMMIT_MSG_GUARD_MARKER}
49
+ // Enforces the commit-message contract (issue #1869): attribution trailers,
50
+ // no bare non-issue #<digits>, and a conventional-commit subject. A
51
+ // per-commit waiver line (${COMMIT_MSG_WAIVER_MARKER}) skips every check
52
+ // below for a deliberate exception.
53
+ import { readFileSync } from "node:fs";
54
+
55
+ // git invokes commit-msg with ONLY the message-file path (unlike
56
+ // prepare-commit-msg, which also gets a source/sha) — no signal distinguishes
57
+ // an ordinary commit from a merge/squash at this hook. A default, unedited
58
+ // merge message ("Merge branch '...'", "Merge pull request #...", "Merge tag
59
+ // '...'"), a default git-revert message (Revert "..."), or a
60
+ // git commit --fixup/--squash autosquash subject (fixup! ... / squash! ...)
61
+ // is git/tooling-generated, not operator-authored prose, so each is exempt by
62
+ // its own recognizable shape rather than forced through a conventional-commit
63
+ // subject and trailers it was never meant to carry.
64
+ const [, , msgPath] = process.argv;
65
+ const message = readFileSync(msgPath, "utf8");
66
+ const subjectLine = message.split("\n", 1)[0] || "";
67
+ if (
68
+ /^Merge (branch|tag|remote-tracking branch|pull request) /u.test(subjectLine) ||
69
+ /^Revert "/u.test(subjectLine) ||
70
+ /^(fixup|squash)! /u.test(subjectLine)
71
+ ) process.exit(0);
72
+
73
+ if (/^${COMMIT_MSG_GUARD_MARKER}:allow\b/mu.test(message)) process.exit(0);
74
+
75
+ const errors = [];
76
+
77
+ // Trailers are required only for an AGENT-authored commit: Claude Code sets
78
+ // CLAUDECODE=1 in every shell it spawns (the same harness-detection signal
79
+ // packages/core/src/loop/run-context.mjs's isClaudeHarness checks) — a plain
80
+ // human commit (CLAUDECODE unset) is never "Claude", so requiring a Claude
81
+ // co-author trailer on it would misattribute the commit, not enforce honesty.
82
+ if (process.env.CLAUDECODE === "1") {
83
+ if (!/^Co-Authored-By:\s*Claude\s+.+\s+<noreply@anthropic\.com>\s*$/imu.test(message)) {
84
+ errors.push("missing required trailer: Co-Authored-By: Claude <model> <noreply@anthropic.com>");
85
+ }
86
+ if (!/^Claude-Session:\s*\S+/imu.test(message)) {
87
+ errors.push("missing required trailer: Claude-Session: <url>");
88
+ }
89
+ }
90
+
91
+ // A genuine "Closes #N" / "Fixes #N" / "Refs #N" reference (optionally a
92
+ // comma/and-joined list, and optionally the trailer colon form "Closes: #N")
93
+ // is allowed and stripped first; any #<digits> left over is a bare non-issue
94
+ // enumeration, which GitHub auto-links to an unrelated issue/PR when
95
+ // rendered.
96
+ const withoutAllowedRefs = message.replace(
97
+ /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|refs?|references?):?\s+#\d+(?:\s*(?:,|and)\s*#\d+)*/giu,
98
+ "",
99
+ );
100
+ if (/#\d+/u.test(withoutAllowedRefs)) {
101
+ errors.push('bare #<digits> reference found; use "Closes #N" / "Fixes #N" / "Refs #N" for a genuine issue reference, or reword a non-issue enumeration (e.g. "defect N")');
102
+ }
103
+
104
+ if (!/^(feat|fix|chore|docs|test|refactor|revert|perf|style|ci|build)\([^()\n]+\): .+\S/u.test(subjectLine)) {
105
+ errors.push("subject must be conventional-commit form \"type(scope): summary\" (type one of feat/fix/chore/docs/test/refactor/revert/perf/style/ci/build)");
106
+ }
107
+
108
+ if (errors.length > 0) {
109
+ console.error("dev-loops: WORKTREE-COMMIT-MSG-GUARD refuses this commit — contract violation(s):");
110
+ for (const error of errors) console.error(" - " + error);
111
+ console.error(" Waiver: add a \"${COMMIT_MSG_WAIVER_MARKER}\" line to the commit message for a deliberate exception.");
112
+ process.exit(1);
113
+ }
114
+ process.exit(0);
115
+ `;
116
+ }
117
+
118
+ /**
119
+ * Install the commit-msg guard into a repository's hook directory. Mirrors
120
+ * default-branch-guard's install-refusal checks (a caller with an unsafe
121
+ * `core.hooksPath`, a non-absolute/non-git `gitDir`, or a linked worktree's
122
+ * OWN gitdir must never report success for a hook that can never fire) and
123
+ * its atomic write + foreign-hook preservation — duplicated rather than
124
+ * shared, since it is one hook, not a family; see default-branch-guard.mjs
125
+ * for the family version if a third hook installer ever needs the same
126
+ * shape factored out.
127
+ *
128
+ * @param {{ gitDir: string, hooksPathOverride?: string|null }} target
129
+ */
130
+ export function installCommitMsgGuard({ gitDir, hooksPathOverride = null }) {
131
+ const refuse = (reason) => ({ ok: false, installed: false, refreshed: false, skipped: true, reason });
132
+
133
+ if (typeof hooksPathOverride === "string") {
134
+ const configured = hooksPathOverride.trim();
135
+ return configured.length > 0
136
+ ? refuse(`core.hooksPath is set to ${JSON.stringify(configured)} — install the guard there, or unset it`)
137
+ : refuse("core.hooksPath is set to an empty string — git runs no hooks at all");
138
+ }
139
+ if (typeof gitDir !== "string" || !path.isAbsolute(gitDir)) {
140
+ return refuse(`gitDir must be an absolute path; got ${JSON.stringify(gitDir)}`);
141
+ }
142
+ if (!fs.existsSync(path.join(gitDir, "HEAD"))) {
143
+ return refuse(`gitDir ${JSON.stringify(gitDir)} does not look like a git directory (no HEAD file)`);
144
+ }
145
+ if (fs.existsSync(path.join(gitDir, "commondir"))) {
146
+ return refuse(`gitDir ${JSON.stringify(gitDir)} is a linked worktree's own git directory, not the common one — hooks installed there never run`);
147
+ }
148
+
149
+ const hooksDir = path.join(gitDir, "hooks");
150
+ fs.mkdirSync(hooksDir, { recursive: true });
151
+ const hookPath = path.join(hooksDir, "commit-msg");
152
+
153
+ const existing = fs.existsSync(hookPath) ? fs.readFileSync(hookPath, "utf8") : null;
154
+ const ours = existing === null || GUARD_MARKER_LINE.test(existing);
155
+ if (!ours) {
156
+ return { ok: true, installed: false, refreshed: false, skipped: true, reason: "a pre-existing hook is present and was left untouched" };
157
+ }
158
+
159
+ // Same atomic tmp-write + rename as default-branch-guard: the hooks dir is
160
+ // shared across worktrees, so a direct writeFileSync would be visible
161
+ // mid-write to a concurrent install or a real commit racing this one.
162
+ const tmpPath = path.join(hooksDir, `.commit-msg.tmp-${process.pid}-${Date.now()}`);
163
+ fs.writeFileSync(tmpPath, renderCommitMsgGuardHook(), { mode: 0o755 });
164
+ fs.chmodSync(tmpPath, 0o755);
165
+ fs.renameSync(tmpPath, hookPath);
166
+
167
+ return { ok: true, installed: existing === null, refreshed: existing !== null, skipped: false };
168
+ }
@@ -9,6 +9,13 @@
9
9
  * `Acceptance criteria` or `DoD` section cause the draft gate to post
10
10
  * `verdict=blocked` with the `missing_refinement_artifact` finding.
11
11
  *
12
+ * Since #1866 the check ALSO requires an explicit Non-goals section on the
13
+ * issue body (see `MISSING_EXPLICIT_NON_GOALS_FINDING` below).
14
+ */
15
+ import { existsSync } from "node:fs";
16
+ import path from "node:path";
17
+
18
+ /**
12
19
  * This module owns:
13
20
  * - canonical section-name matching for AC / DoD blocks
14
21
  * - bullet-item extraction (checklist `- [ ]`/`- [x]` and top-level `- ` bullets)
@@ -38,6 +45,15 @@ export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
38
45
  "linked refinement doc",
39
46
  ]);
40
47
 
48
+ /**
49
+ * #1866: finding reported when the issue body carries a refinement artifact
50
+ * (AC/DoD checklist or a resolvable linked doc) but no explicit Non-goals
51
+ * section. Mirrors the PR-path narrative-invariant code
52
+ * (`PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.code`) so both spec surfaces
53
+ * name the missing invariant identically.
54
+ */
55
+ export const MISSING_EXPLICIT_NON_GOALS_FINDING = "missing_explicit_non_goals";
56
+
41
57
  /**
42
58
  * Canonical list of section headings that satisfy the refinement check.
43
59
  * Matching is case-insensitive and tolerates trailing/leading whitespace.
@@ -248,6 +264,13 @@ export function detectLinkedRefinementDoc(body) {
248
264
 
249
265
  const pathMatch = /(?:^|\s|[`(\[<])(tmp\/refinement\/[A-Za-z0-9._/\-]+\.md)\b/u.exec(body);
250
266
  if (pathMatch) {
267
+ // Containment guard: reject actual '..' path segments (not benign
268
+ // double-dot filenames) so the new fs-probe wiring can never be used as a
269
+ // filesystem existence oracle outside tmp/refinement
270
+ // (e.g. `tmp/refinement/../../docs/some-existing.md`).
271
+ if (pathMatch[1].split("/").some((segment) => segment === "..")) {
272
+ return { found: false, path: null, reason: "path-escapes-refinement-dir" };
273
+ }
251
274
  return { found: true, path: pathMatch[1], reason: "explicit-path" };
252
275
  }
253
276
 
@@ -261,6 +284,11 @@ export function detectLinkedRefinementDoc(body) {
261
284
  if (refinementSection) {
262
285
  const inlinePath = /(?:^|\s)(tmp\/refinement\/[^\s)`'"]+\.md)\b/u.exec(refinementSection.bodyLines.join("\n"));
263
286
  if (inlinePath) {
287
+ // Containment guard: same segment-based '..' rejection as the
288
+ // explicit-path branch.
289
+ if (inlinePath[1].split("/").some((segment) => segment === "..")) {
290
+ return { found: false, path: null, reason: "path-escapes-refinement-dir" };
291
+ }
264
292
  return { found: true, path: inlinePath[1], reason: "refinement-section-path" };
265
293
  }
266
294
  }
@@ -271,25 +299,56 @@ export function detectLinkedRefinementDoc(body) {
271
299
  /**
272
300
  * Detect the refinement artifact on a parsed issue body.
273
301
  *
302
+ * #1866: the tracker-backed refinement floor is the artifact (AC checklist,
303
+ * DoD checklist, or a resolvable linked refinement doc) AND an explicit,
304
+ * non-empty Non-goals section — the loop-grill / artifact-authority contract
305
+ * requires Non-goals on a refined issue body, so the deterministic check
306
+ * enforces it (fail-closed) with the distinct finding
307
+ * `MISSING_EXPLICIT_NON_GOALS_FINDING`. The non-goals matcher is shared with
308
+ * `validatePrBodySpec` (`PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns`),
309
+ * so the two spec surfaces cannot drift on what counts as an explicit
310
+ * Non-goals section. `hasACs` keeps its caller-facing meaning: true only when
311
+ * the FULL check passes, so every `.hasACs` consumer (enqueue gate, draft
312
+ * gate, parked-items discovery, gate context) fails closed with no call-site
313
+ * change.
314
+ *
315
+ * `resolveLinkedDoc` (optional, #1866): a `(path) => boolean` callback used to
316
+ * verify that a linked `tmp/refinement/*.md` doc actually resolves (e.g.
317
+ * `existsSync`). Enforcement-point callers (enqueue gate, draft-gate
318
+ * linked-issue path) supply it; a linked doc found in the body then satisfies
319
+ * the artifact check only when the callback returns true. When the callback is
320
+ * not supplied the predicate stays pure/no-I/O and behavior is unchanged, and
321
+ * the `linkedDoc` result carries no `resolves` field. When supplied and the
322
+ * doc does not resolve, `linkedDoc.resolves === false` and the linked doc does
323
+ * not satisfy the artifact check (other artifact sources still count).
324
+ *
325
+ * Result-shape note: on a `missing_explicit_non_goals` result, `source` keeps
326
+ * the detected artifact origin (e.g. `issue-body-ac`) so callers/reporting can
327
+ * still see what artifact exists; `hasACs` is false because the full
328
+ * refinement check did not pass.
329
+ *
274
330
  * @param {object} input
275
331
  * @param {string} [input.body] Raw issue body Markdown.
276
332
  * @param {number} [input.issueNumber] Issue number, used for linked-doc convention.
333
+ * @param {Function} [input.resolveLinkedDoc] Optional `(path) => boolean` doc-resolution check.
277
334
  * @returns {{
278
335
  * hasACs: boolean,
336
+ * hasNonGoals: boolean,
279
337
  * source: string,
280
338
  * acItems: string[],
281
339
  * uncheckedAcItems: string[],
282
340
  * dodItems: string[],
283
341
  * sections: string[],
284
- * linkedDoc: { found: boolean, path: string|null, reason: string },
342
+ * linkedDoc: { found: boolean, path: string|null, reason: string, resolves?: boolean },
285
343
  * reason: string,
286
344
  * finding: string|null,
287
345
  * }}
288
346
  */
289
- export function detectIssueRefinementArtifact({ body = "", issueNumber = null } = {}) {
347
+ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, resolveLinkedDoc = null } = {}) {
290
348
  if (typeof body !== "string" || body.length === 0) {
291
349
  return {
292
350
  hasACs: false,
351
+ hasNonGoals: false,
293
352
  source: REFINEMENT_SOURCE.MISSING,
294
353
  acItems: [],
295
354
  uncheckedAcItems: [],
@@ -315,58 +374,88 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
315
374
  const uncheckedAcItems = acceptanceSection ? extractUncheckedChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
316
375
  const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
317
376
 
318
- const linkedDoc = detectLinkedRefinementDoc(body);
319
-
320
- if (acItems.length > 0) {
321
- return {
322
- hasACs: true,
323
- source: REFINEMENT_SOURCE.ISSUE_BODY_AC,
324
- acItems,
325
- uncheckedAcItems,
326
- dodItems,
327
- sections: sectionNames,
328
- linkedDoc,
329
- reason: `Found ${acItems.length} Acceptance criteria checklist item(s) in the issue body.`,
330
- finding: null,
331
- };
377
+ let linkedDoc = detectLinkedRefinementDoc(body);
378
+ let linkedDocResolves = linkedDoc.found;
379
+ if (linkedDoc.found && typeof resolveLinkedDoc === "function") {
380
+ linkedDocResolves = resolveLinkedDoc(linkedDoc.path) === true;
381
+ linkedDoc = { ...linkedDoc, resolves: linkedDocResolves };
332
382
  }
333
383
 
334
- if (dodItems.length > 0) {
335
- return {
336
- hasACs: true,
337
- source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
338
- acItems,
339
- uncheckedAcItems,
340
- dodItems,
341
- sections: sectionNames,
342
- linkedDoc,
343
- reason: `Found ${dodItems.length} DoD checklist item(s) in the issue body.`,
344
- finding: null,
345
- };
346
- }
384
+ // #1866: explicit Non-goals section required on a refined tracker-backed
385
+ // issue body — same matcher the PR-body spec path uses, so the two cannot
386
+ // drift. A heading-only or fenced-only section does not count
387
+ // (sectionHasBody anti-spoof).
388
+ const hasNonGoals = sectionHasBody(
389
+ findSectionByPatterns(sections, PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns),
390
+ );
391
+
392
+ const artifactSource = acItems.length > 0
393
+ ? REFINEMENT_SOURCE.ISSUE_BODY_AC
394
+ : dodItems.length > 0
395
+ ? REFINEMENT_SOURCE.ISSUE_BODY_DOD
396
+ : linkedDocResolves
397
+ ? REFINEMENT_SOURCE.LINKED_DOC
398
+ : null;
347
399
 
348
- if (linkedDoc.found) {
400
+ const base = {
401
+ hasNonGoals,
402
+ acItems,
403
+ uncheckedAcItems,
404
+ dodItems,
405
+ sections: sectionNames,
406
+ linkedDoc,
407
+ };
408
+
409
+ if (artifactSource !== null) {
410
+ if (!hasNonGoals) {
411
+ return {
412
+ ...base,
413
+ hasACs: false,
414
+ source: artifactSource,
415
+ reason:
416
+ `Issue body carries a refinement artifact (${artifactSource}) but no explicit Non-goals section; ` +
417
+ "the tracker-backed refinement contract requires one (rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; " +
418
+ "e.g. run the loop-grill synthesis). Refusing: the refinement check fails closed without an explicit Non-goals section.",
419
+ finding: MISSING_EXPLICIT_NON_GOALS_FINDING,
420
+ };
421
+ }
422
+ if (artifactSource === REFINEMENT_SOURCE.ISSUE_BODY_AC) {
423
+ return {
424
+ ...base,
425
+ hasACs: true,
426
+ source: REFINEMENT_SOURCE.ISSUE_BODY_AC,
427
+ reason: `Found ${acItems.length} Acceptance criteria checklist item(s) in the issue body.`,
428
+ finding: null,
429
+ };
430
+ }
431
+ if (artifactSource === REFINEMENT_SOURCE.ISSUE_BODY_DOD) {
432
+ return {
433
+ ...base,
434
+ hasACs: true,
435
+ source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
436
+ reason: `Found ${dodItems.length} DoD checklist item(s) in the issue body.`,
437
+ finding: null,
438
+ };
439
+ }
349
440
  return {
441
+ ...base,
350
442
  hasACs: true,
351
443
  source: REFINEMENT_SOURCE.LINKED_DOC,
352
444
  acItems: [],
353
445
  uncheckedAcItems: [],
354
446
  dodItems: [],
355
- sections: sectionNames,
356
- linkedDoc,
357
447
  reason: `Issue body links a refinement doc at ${linkedDoc.path}; treating that as the refinement artifact source.`,
358
448
  finding: null,
359
449
  };
360
450
  }
361
451
 
362
452
  return {
453
+ ...base,
363
454
  hasACs: false,
364
455
  source: REFINEMENT_SOURCE.MISSING,
365
456
  acItems: [],
366
457
  uncheckedAcItems: [],
367
458
  dodItems: [],
368
- sections: sectionNames,
369
- linkedDoc,
370
459
  reason: "Issue body has no Acceptance criteria section, no DoD section, and no linked refinement doc.",
371
460
  finding: REFINEMENT_ARTIFACT_FINDING,
372
461
  };
@@ -484,7 +573,16 @@ function sectionHasBody(section) {
484
573
  * pick exactly one mode (tracker-backed, with or without a specific
485
574
  * expected issue) or issue-less — never both.
486
575
  *
487
- * @param {{ body?: string, expectedIssue?: number, issueLess?: boolean }} input
576
+ * `requireOpenQuestions` (default `true`, issue #1863): the lightweight
577
+ * PR-body-as-spec contract (this function's original scope) requires an Open
578
+ * questions/risks section; the ordinary tracker-backed PR-description
579
+ * contract (skills/docs/copilot-loop-operations.md "PR description
580
+ * contract") does not name one. Pass `false` (see
581
+ * `validateTrackerBackedPrBodySpec` below) to skip the `missing_open_questions`
582
+ * check without touching any other invariant — the lightweight caller's
583
+ * default stays byte-identical.
584
+ *
585
+ * @param {{ body?: string, expectedIssue?: number, issueLess?: boolean, requireOpenQuestions?: boolean }} input
488
586
  * @returns {{ checker: "validate-pr-body-spec", ok: boolean, errors: { code: string, message: string }[], sections: string[], acItems: string[], dodItems: string[], closesIssues: number[] }}
489
587
  */
490
588
 
@@ -540,7 +638,7 @@ export function detectGrillEmbedHeading(body = "") {
540
638
  return null;
541
639
  }
542
640
 
543
- export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess = false } = {}) {
641
+ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess = false, requireOpenQuestions = true } = {}) {
544
642
  if (issueLess && Number.isInteger(expectedIssue)) {
545
643
  // Fail closed at the library boundary too (not just the CLI): the two modes
546
644
  // are contradictory and silently preferring one would hide caller bugs.
@@ -550,7 +648,8 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
550
648
  const sections = parseMarkdownSections(bodyText);
551
649
  const errors = [];
552
650
 
553
- for (const { code, label, patterns } of Object.values(PR_BODY_SPEC_NARRATIVE_SECTIONS)) {
651
+ for (const [key, { code, label, patterns }] of Object.entries(PR_BODY_SPEC_NARRATIVE_SECTIONS)) {
652
+ if (key === "open_questions" && !requireOpenQuestions) continue;
554
653
  const section = findSectionByPatterns(sections, patterns);
555
654
  if (!sectionHasBody(section)) {
556
655
  errors.push({ code, message: `Missing or empty ${label} section.` });
@@ -606,6 +705,31 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
606
705
  };
607
706
  }
608
707
 
708
+ /**
709
+ * Validate a TRACKER-BACKED PR's own body against the PR-description contract
710
+ * (skills/docs/copilot-loop-operations.md "PR description contract", issue
711
+ * #1863): Acceptance criteria + Definition of done checklists, an explicit
712
+ * Non-goals section, and a `Closes #N`/`Fixes #N` reference — regardless of
713
+ * whether the linked issue itself already carries a refinement artifact. A
714
+ * linked issue with real ACs is necessary but not sufficient: the PR body is
715
+ * the portable spec-of-record a tracker-agnostic consumer reads.
716
+ *
717
+ * Thin wrapper over `validatePrBodySpec`, not a second divergent checker:
718
+ * `requireOpenQuestions: false` because the tracker-backed contract, unlike
719
+ * the lightweight PR-body-as-spec path, does not require an Open
720
+ * questions/risks section. `expectedIssue` is only checked when the PR closes
721
+ * exactly ONE issue — an umbrella PR closing several is not required to name
722
+ * any single one of them in the `expectedIssue` slot (each linked issue's
723
+ * refinement is verified separately by the caller).
724
+ *
725
+ * @param {{ body?: string, closingIssues?: number[] }} input
726
+ * @returns {ReturnType<typeof validatePrBodySpec>}
727
+ */
728
+ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = [] } = {}) {
729
+ const expectedIssue = Array.isArray(closingIssues) && closingIssues.length === 1 ? closingIssues[0] : null;
730
+ return validatePrBodySpec({ body, expectedIssue, requireOpenQuestions: false });
731
+ }
732
+
609
733
  /**
610
734
  * Decide what an enqueue caller should do with a refinement-artifact result,
611
735
  * so an un-refined item never lands in the Next Up pickup column in the first
@@ -623,12 +747,22 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
623
747
  * @returns {{ action: "enqueue" } | { action: "block"|"divert", reason: string, missing: string[] }}
624
748
  */
625
749
  export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = false }) {
626
- // `artifact.finding === null` is the explicit "has ANY refinement artifact"
627
- // signal (AC checklist OR DoD checklist OR linked doc) clearer than reading
628
- // `hasACs`, whose name understates that a DoD or linked doc also satisfies it.
750
+ // `artifact.finding === null` is the explicit "passes the full refinement
751
+ // check" signal (artifact AND since #1866an explicit Non-goals
752
+ // section), clearer than reading `hasACs`, whose name understates what it
753
+ // covers.
629
754
  if (!targetIsPickup || artifact.finding === null) {
630
755
  return { action: "enqueue" };
631
756
  }
757
+ // #1866: artifact present but the contract-mandated Non-goals section is
758
+ // absent/empty — a distinct failure with its own guidance.
759
+ if (artifact.finding === MISSING_EXPLICIT_NON_GOALS_FINDING) {
760
+ const reason =
761
+ "Issue carries a refinement artifact but no explicit Non-goals section. " +
762
+ "Add an explicit `## Non-goals` section to the issue body " +
763
+ "(rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; e.g. run `/dev-loops:loop-grill <issue> --auto` (or `/loop-grill <issue> --auto` in the dev-loops repo itself)) — refusing to enqueue without an explicit Non-goals section.";
764
+ return { action: auto ? "divert" : "block", reason, missing: ["explicit Non-goals section"] };
765
+ }
632
766
  const missing = [...REFINEMENT_ARTIFACT_SOURCES];
633
767
  const reason =
634
768
  `Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
@@ -649,7 +783,7 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
649
783
  * @param {{ issueNumber: number, repo: string, env: object, runChild: Function, auto?: boolean }} input
650
784
  * @returns {Promise<{ action: "enqueue" } | { action: "divert"|"block", reason: string, missing: string[] }>}
651
785
  */
652
- export async function runPickupRefinementGate({ issueNumber, repo, env, runChild, auto = false }) {
786
+ export async function runPickupRefinementGate({ issueNumber, repo, env, runChild, auto = false, repoRoot = null }) {
653
787
  const bodyResult = await runChild(
654
788
  "gh",
655
789
  ["issue", "view", String(issueNumber), "--repo", repo, "--json", "body"],
@@ -666,7 +800,17 @@ export async function runPickupRefinementGate({ issueNumber, repo, env, runChild
666
800
  throw new Error("Invalid JSON input");
667
801
  }
668
802
  const body = typeof bodyPayload?.body === "string" ? bodyPayload.body : "";
669
- const artifact = detectIssueRefinementArtifact({ body, issueNumber });
803
+ // #1866: a linked refinement doc satisfies the gate only when it actually
804
+ // resolves. Paths follow the `tmp/refinement/*.md` convention and are
805
+ // anchored to the caller's repo root (`repoRoot` option, falling back to
806
+ // process.cwd()) — never the ambient cwd of whichever subdirectory the
807
+ // gate happened to run from.
808
+ const docAnchor = repoRoot ?? process.cwd();
809
+ const artifact = detectIssueRefinementArtifact({
810
+ body,
811
+ issueNumber,
812
+ resolveLinkedDoc: (p) => existsSync(path.isAbsolute(p) ? p : path.resolve(docAnchor, p)),
813
+ });
670
814
  const decision = decideEnqueueRefinementGate({ artifact, targetIsPickup: true, auto });
671
815
  if (decision.action === "block") {
672
816
  throw Object.assign(new Error(decision.reason), {
@@ -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";
@@ -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
  }