@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
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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.
|
|
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 "
|
|
627
|
-
// signal (
|
|
628
|
-
// `hasACs`, whose name understates
|
|
750
|
+
// `artifact.finding === null` is the explicit "passes the full refinement
|
|
751
|
+
// check" signal (artifact AND — since #1866 — an 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
|
-
|
|
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
|
-
|
|
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
|
}
|