dev-loops 0.7.1 → 0.8.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/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +7 -7
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/refiner.md +4 -4
- package/.claude/agents/review.md +10 -13
- package/.claude/commands/loop-continue.md +2 -1
- package/.claude/commands/loop-enqueue.md +9 -2
- package/.claude/commands/loop-info.md +1 -1
- package/.claude/commands/loop-start-spike.md +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +7 -6
- package/.claude/hooks/_hook-decisions.mjs +5 -4
- package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
- package/.claude/skills/dev-loop/SKILL.md +8 -8
- package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
- package/.claude/skills/docs/anti-patterns.md +6 -5
- package/.claude/skills/docs/artifact-authority-contract.md +17 -14
- package/.claude/skills/docs/confirmation-rules.md +2 -1
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +21 -15
- package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
- package/.claude/skills/docs/debt-remediation-contract.md +1 -1
- package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/.claude/skills/docs/issue-intake-procedure.md +37 -22
- package/.claude/skills/docs/merge-preconditions.md +17 -13
- package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
- package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
- package/.claude/skills/docs/spike-mode-contract.md +14 -7
- package/.claude/skills/docs/stop-conditions.md +30 -15
- package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
- package/.claude/skills/docs/validation-policy.md +4 -5
- package/.claude/skills/local-implementation/SKILL.md +49 -143
- package/.claude/skills/loop-grill/SKILL.md +34 -14
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +36 -0
- package/README.md +95 -189
- package/agents/dev-loop.agent.md +8 -8
- package/agents/fixer.agent.md +1 -0
- package/agents/refiner.agent.md +4 -4
- package/agents/review.agent.md +10 -13
- package/extension/README.md +5 -4
- package/package.json +7 -5
- package/scripts/_core-helpers.mjs +2 -0
- package/scripts/claude/headless-dev-loop.mjs +1 -1
- package/scripts/docs/validate-rule-ownership.mjs +442 -0
- package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
- package/scripts/github/_gate-names.mjs +5 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- package/scripts/github/capture-review-threads.mjs +2 -2
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
- package/scripts/github/edit-issue.mjs +259 -0
- package/scripts/github/list-review-threads.mjs +277 -0
- package/scripts/github/post-gate-findings.mjs +6 -3
- package/scripts/github/probe-ci-status.mjs +18 -0
- package/scripts/github/probe-copilot-review.mjs +24 -3
- package/scripts/github/reconcile-draft-gate.mjs +13 -13
- package/scripts/github/request-copilot-review.mjs +109 -42
- package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
- package/scripts/github/verify-briefing-prefixes.mjs +382 -0
- package/scripts/github/verify-fresh-review-context.mjs +78 -3
- package/scripts/github/wait-pr-checks.mjs +171 -0
- package/scripts/github/write-gate-context.mjs +220 -10
- package/scripts/github/write-gate-findings-log.mjs +54 -2
- package/scripts/loop/_post-convergence-change.mjs +2 -2
- package/scripts/loop/_pr-runner-coordination.mjs +112 -13
- package/scripts/loop/check-retro-tooling.mjs +14 -9
- package/scripts/loop/copilot-pr-handoff.mjs +47 -16
- package/scripts/loop/detect-change-scope.mjs +2 -2
- package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
- package/scripts/loop/detect-internal-only-pr.mjs +6 -6
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
- package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
- package/scripts/loop/run-watch-cycle.mjs +42 -7
- package/scripts/loop/sanctioned-commands.mjs +3 -0
- package/scripts/loop/validate-pr-body-spec.mjs +21 -5
- package/scripts/pages/build-state-atlas.mjs +65 -48
- package/scripts/projects/_resolve-project.mjs +1 -148
- package/scripts/projects/add-queue-item.mjs +87 -4
- package/scripts/projects/list-queue-items.mjs +3 -377
- package/scripts/projects/move-queue-item.mjs +3 -410
- package/scripts/projects/reorder-queue-item.mjs +3 -22
- package/skills/copilot-pr-followup/SKILL.md +42 -28
- package/skills/dev-loop/SKILL.md +3 -3
- package/skills/docs/acceptance-criteria-verification.md +13 -4
- package/skills/docs/anti-patterns.md +6 -5
- package/skills/docs/artifact-authority-contract.md +17 -14
- package/skills/docs/confirmation-rules.md +2 -1
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +21 -15
- package/skills/docs/cross-harness-regression-contract.md +2 -2
- package/skills/docs/debt-remediation-contract.md +1 -1
- package/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/skills/docs/issue-intake-procedure.md +37 -22
- package/skills/docs/merge-preconditions.md +17 -13
- package/skills/docs/pr-lifecycle-contract.md +45 -36
- package/skills/docs/public-dev-loop-contract.md +61 -44
- package/skills/docs/required-rules.json +165 -0
- package/skills/docs/retrospective-checkpoint-contract.md +25 -9
- package/skills/docs/spike-mode-contract.md +14 -7
- package/skills/docs/stop-conditions.md +30 -15
- package/skills/docs/tracker-first-loop-state.md +13 -8
- package/skills/docs/validation-policy.md +4 -5
- package/skills/local-implementation/SKILL.md +49 -143
- package/skills/loop-grill/SKILL.md +38 -17
- package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
|
@@ -10,12 +10,13 @@ import {
|
|
|
10
10
|
resolveDraftGateRoundResetMs,
|
|
11
11
|
summarizeCopilotReviews,
|
|
12
12
|
} from "../_core-helpers.mjs";
|
|
13
|
-
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
14
|
-
import { loadDevLoopConfig, resolveGateConfig, resolveRefinement, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
|
|
13
|
+
import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
|
|
14
|
+
import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveGateConfig, resolveRefinement, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
|
|
15
15
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
16
16
|
import { buildSnapshotFromPrFacts, interpretLoopState, isCopilotRoundCapReached, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
|
|
17
|
-
import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
|
|
17
|
+
import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION, REFINEMENT_ARTIFACT_SPEC_SOURCE } from "@dev-loops/core/loop/pr-gate-coordination";
|
|
18
18
|
import { shouldGuardCopilotReviewRequest } from "@dev-loops/core/loop/pr-gate-coordination";
|
|
19
|
+
import { PLAN_FILE_PROMOTION_DOC_PATH_PATTERN } from "@dev-loops/core/loop/plan-file-promote-contract";
|
|
19
20
|
import { UI_E2E_CHECK_NAMES } from "@dev-loops/core/loop/ui-e2e-scoping";
|
|
20
21
|
import { fetchGithubReviewThreadsPayload } from "../github/capture-review-threads.mjs";
|
|
21
22
|
import { detectPostConvergenceSignificantChange } from "./_post-convergence-change.mjs";
|
|
@@ -30,6 +31,12 @@ Required:
|
|
|
30
31
|
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
31
32
|
--pr <number> Pull request number
|
|
32
33
|
Optional:
|
|
34
|
+
--lightweight This PR is light-dispatched (#1210): compose the Copilot
|
|
35
|
+
round cap with localImplementation.lightMode.maxCopilotRounds
|
|
36
|
+
(default 1) via min(lightMode.maxCopilotRounds,
|
|
37
|
+
refinement.maxCopilotRounds) instead of using
|
|
38
|
+
refinement.maxCopilotRounds alone. refinement.maxCopilotRounds:
|
|
39
|
+
0 still disables Copilot rounds even with --lightweight.
|
|
33
40
|
Output (stdout, JSON):
|
|
34
41
|
{
|
|
35
42
|
"ok": true,
|
|
@@ -61,11 +68,19 @@ Output (stdout, JSON):
|
|
|
61
68
|
"headSha": null,
|
|
62
69
|
"verdict": null
|
|
63
70
|
},
|
|
71
|
+
"refinementArtifact": {
|
|
72
|
+
"status": "present",
|
|
73
|
+
"specSource": "linked_issue",
|
|
74
|
+
"reason": "...",
|
|
75
|
+
"finding": null
|
|
76
|
+
},
|
|
64
77
|
"allowedNextActions": ["resolve_merge_conflicts"],
|
|
65
78
|
"forbiddenActions": ["run_pre_approval_gate", "declare_merge_ready"],
|
|
66
79
|
"nextAction": "resolve_merge_conflicts",
|
|
67
80
|
"reason": "..."
|
|
68
81
|
}
|
|
82
|
+
(refinementArtifact.planDocPath is present only when specSource is
|
|
83
|
+
"plan_file"; the key is omitted entirely for "linked_issue" and "pr_body")
|
|
69
84
|
Error output (stderr, JSON):
|
|
70
85
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
71
86
|
{ "ok": false, "error": "..." }
|
|
@@ -80,6 +95,7 @@ export function parseDetectPrGateCoordinationCliArgs(argv) {
|
|
|
80
95
|
help: false,
|
|
81
96
|
repo: undefined,
|
|
82
97
|
pr: undefined,
|
|
98
|
+
lightweight: false,
|
|
83
99
|
};
|
|
84
100
|
const { tokens } = parseArgs({
|
|
85
101
|
args: [...argv],
|
|
@@ -87,6 +103,7 @@ export function parseDetectPrGateCoordinationCliArgs(argv) {
|
|
|
87
103
|
help: { type: "boolean", short: "h" },
|
|
88
104
|
repo: { type: "string" },
|
|
89
105
|
pr: { type: "string" },
|
|
106
|
+
lightweight: { type: "boolean" },
|
|
90
107
|
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
91
108
|
},
|
|
92
109
|
allowPositionals: true,
|
|
@@ -112,6 +129,10 @@ export function parseDetectPrGateCoordinationCliArgs(argv) {
|
|
|
112
129
|
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
113
130
|
continue;
|
|
114
131
|
}
|
|
132
|
+
if (token.name === "lightweight") {
|
|
133
|
+
options.lightweight = true;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
115
136
|
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
116
137
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
117
138
|
}
|
|
@@ -155,7 +176,7 @@ export function parseGitStatusConflictFiles(text) {
|
|
|
155
176
|
}
|
|
156
177
|
return conflictFiles;
|
|
157
178
|
}
|
|
158
|
-
async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
179
|
+
async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
159
180
|
const result = await runChild(
|
|
160
181
|
ghCommand,
|
|
161
182
|
["api", `repos/${repo}/pulls/${pr}/requested_reviewers`],
|
|
@@ -167,7 +188,7 @@ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghComm
|
|
|
167
188
|
}
|
|
168
189
|
return parseRequestedReviewersPayload(result.stdout);
|
|
169
190
|
}
|
|
170
|
-
async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
191
|
+
async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
171
192
|
const result = await runChild(
|
|
172
193
|
ghCommand,
|
|
173
194
|
["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeable,mergeStateStatus,body,title,closingIssuesReferences,reviews,statusCheckRollup,files"],
|
|
@@ -190,18 +211,19 @@ export async function fetchPrFactsWithSettledMergeable(
|
|
|
190
211
|
{
|
|
191
212
|
env = process.env,
|
|
192
213
|
ghCommand = "gh",
|
|
214
|
+
runChild = defaultRunChild,
|
|
193
215
|
maxPolls = 3,
|
|
194
216
|
pollDelayMs = 1500,
|
|
195
217
|
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
196
218
|
fetch = fetchPrFacts,
|
|
197
219
|
} = {},
|
|
198
220
|
) {
|
|
199
|
-
let prData = await fetch(options, { env, ghCommand });
|
|
221
|
+
let prData = await fetch(options, { env, ghCommand, runChild });
|
|
200
222
|
let polls = 0;
|
|
201
223
|
while (String(prData?.mergeable || "").toUpperCase() === "UNKNOWN" && polls < maxPolls) {
|
|
202
224
|
polls += 1;
|
|
203
225
|
await sleep(pollDelayMs);
|
|
204
|
-
prData = await fetch(options, { env, ghCommand });
|
|
226
|
+
prData = await fetch(options, { env, ghCommand, runChild });
|
|
205
227
|
}
|
|
206
228
|
return prData;
|
|
207
229
|
}
|
|
@@ -259,7 +281,7 @@ export function resolveLinkedIssuesFromPr(prData) {
|
|
|
259
281
|
return dedupe(matches.map((m) => Number((/(\d+)/.exec(m) || [])[1])));
|
|
260
282
|
}
|
|
261
283
|
|
|
262
|
-
async function fetchIssueBody({ repo, issue }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
284
|
+
async function fetchIssueBody({ repo, issue }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
263
285
|
const result = await runChild(
|
|
264
286
|
ghCommand,
|
|
265
287
|
["issue", "view", String(issue), "--repo", repo, "--json", "body"],
|
|
@@ -275,18 +297,107 @@ async function fetchIssueBody({ repo, issue }, { env = process.env, ghCommand =
|
|
|
275
297
|
return null;
|
|
276
298
|
}
|
|
277
299
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
300
|
+
// Plan-file promotion PRs (P4, `buildPromotionPrBody`) carry no linked issue by
|
|
301
|
+
// design; the PR body instead names the committed plan doc as the spec-of-record
|
|
302
|
+
// verbatim (marker owned by plan-file-promote-contract.mjs). Matching that
|
|
303
|
+
// literal marker is how the refinement check tells a promoted plan-file PR
|
|
304
|
+
// apart from an ordinary issue-less body.
|
|
305
|
+
|
|
306
|
+
// Resolve the refinement artifact for a draft PR with zero linked issues,
|
|
307
|
+
// directly from its own body. The sanctioned three-origin model
|
|
308
|
+
// (artifact-authority-contract.md) backs a draft PR with a linked issue, a
|
|
309
|
+
// promoted plan file, or the PR body itself as the spec-of-record; only the
|
|
310
|
+
// first fetches an issue body, so this never needs a network call.
|
|
311
|
+
// `specSource` here (linked_issue|pr_body|plan_file) is a distinct enum from
|
|
312
|
+
// handoff-envelope.mjs's CANONICAL_SPEC_SOURCE (phase_doc|pr_body): same field
|
|
313
|
+
// name, different object, different value space — never conflate the two.
|
|
314
|
+
async function resolveNoIssueRefinementArtifact(body) {
|
|
315
|
+
const { detectIssueRefinementArtifact, validatePrBodySpec } = await import("@dev-loops/core/loop/issue-refinement-artifact");
|
|
316
|
+
const planDocMatch = PLAN_FILE_PROMOTION_DOC_PATH_PATTERN.exec(body);
|
|
317
|
+
if (planDocMatch) {
|
|
318
|
+
const planDocPath = planDocMatch[1].trim();
|
|
319
|
+
// The marker sentence alone is spoofable (any body can paste it): require the
|
|
320
|
+
// body to still carry real AC/DoD checklist content, the same invariant
|
|
321
|
+
// `buildPromotionPrBody` actually embeds, before trusting the plan-file claim.
|
|
322
|
+
// Deliberately narrower than `artifact.hasACs`: that flag also turns true on a
|
|
323
|
+
// bare linked-refinement-doc mention (no checklist items at all), which is the
|
|
324
|
+
// right OR for a linked ISSUE body but not for a plan-file promotion PR body —
|
|
325
|
+
// a plan-file claim must be backed by actual AC/DoD checklist items, not just
|
|
326
|
+
// another doc mention next to the marker.
|
|
327
|
+
// Plan-file promotion is issue-less by design (`buildPromotionPrBody`
|
|
328
|
+
// neutralizes closing keywords): any closing reference the body carries —
|
|
329
|
+
// including the cross-repo `owner/repo#N` form validatePrBodySpec also
|
|
330
|
+
// accepts — means the body would auto-close an issue it doesn't actually
|
|
331
|
+
// back. Reuse validatePrBodySpec's closing-reference extraction
|
|
332
|
+
// (issueLess mode's `closesIssues`) instead of a second regex.
|
|
333
|
+
const closesIssues = validatePrBodySpec({ body, issueLess: true }).closesIssues;
|
|
334
|
+
if (closesIssues.length > 0) {
|
|
282
335
|
return {
|
|
283
336
|
status: "missing",
|
|
284
337
|
linkedIssue: null,
|
|
285
338
|
linkedIssues: [],
|
|
286
|
-
|
|
339
|
+
specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.PLAN_FILE,
|
|
340
|
+
planDocPath,
|
|
341
|
+
reason: `PR body names the promoted plan doc \`${planDocPath}\` as the spec-of-record but also carries a GitHub closing reference (${closesIssues.map((n) => `#${n}`).join(", ")}); plan-file promotion is issue-less by design and must not carry a reference that would auto-close an issue it doesn't back.`,
|
|
287
342
|
finding: "missing_refinement_artifact",
|
|
288
343
|
};
|
|
289
344
|
}
|
|
345
|
+
const artifact = detectIssueRefinementArtifact({ body });
|
|
346
|
+
if (artifact.acItems.length > 0 || artifact.dodItems.length > 0) {
|
|
347
|
+
return {
|
|
348
|
+
status: "present",
|
|
349
|
+
linkedIssue: null,
|
|
350
|
+
linkedIssues: [],
|
|
351
|
+
specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.PLAN_FILE,
|
|
352
|
+
planDocPath,
|
|
353
|
+
acItems: artifact.acItems,
|
|
354
|
+
dodItems: artifact.dodItems,
|
|
355
|
+
sections: artifact.sections,
|
|
356
|
+
reason: `Refinement artifact present via the promoted plan doc \`${planDocPath}\` the PR body carries as the spec-of-record (plan-file promotion; no linked issue required).`,
|
|
357
|
+
finding: null,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
status: "missing",
|
|
362
|
+
linkedIssue: null,
|
|
363
|
+
linkedIssues: [],
|
|
364
|
+
specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.PLAN_FILE,
|
|
365
|
+
planDocPath,
|
|
366
|
+
reason: `PR body names the promoted plan doc \`${planDocPath}\` as the spec-of-record but carries no Acceptance criteria / DoD checklist content (${artifact.reason}); a bare marker sentence cannot satisfy the refinement check.`,
|
|
367
|
+
finding: "missing_refinement_artifact",
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
const specResult = validatePrBodySpec({ body, issueLess: true });
|
|
371
|
+
if (specResult.ok) {
|
|
372
|
+
return {
|
|
373
|
+
status: "present",
|
|
374
|
+
linkedIssue: null,
|
|
375
|
+
linkedIssues: [],
|
|
376
|
+
specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.PR_BODY,
|
|
377
|
+
acItems: specResult.acItems,
|
|
378
|
+
dodItems: specResult.dodItems,
|
|
379
|
+
sections: specResult.sections,
|
|
380
|
+
reason: "Refinement artifact present via the PR body itself (issue-less lightweight PR-body-as-spec; validate-pr-body-spec --no-issue clean).",
|
|
381
|
+
finding: null,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
status: "missing",
|
|
386
|
+
linkedIssue: null,
|
|
387
|
+
linkedIssues: [],
|
|
388
|
+
specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.PR_BODY,
|
|
389
|
+
reason: `PR body fails the issue-less lightweight spec-of-record validation (validate-pr-body-spec --no-issue: ${specResult.errors.map((e) => e.code).join(", ")}).`,
|
|
390
|
+
finding: "missing_refinement_artifact",
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed, prMerged }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
395
|
+
const linkedIssues = resolveLinkedIssuesFromPr(prData);
|
|
396
|
+
if (linkedIssues.length === 0) {
|
|
397
|
+
if (prDraft) {
|
|
398
|
+
const body = typeof prData?.body === "string" ? prData.body : "";
|
|
399
|
+
return resolveNoIssueRefinementArtifact(body);
|
|
400
|
+
}
|
|
290
401
|
return {
|
|
291
402
|
status: "unknown",
|
|
292
403
|
linkedIssue: null,
|
|
@@ -308,7 +419,7 @@ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed,
|
|
|
308
419
|
// is refined if AT LEAST ONE linked issue carries a refinement artifact.
|
|
309
420
|
const evaluated = [];
|
|
310
421
|
for (const issue of linkedIssues) {
|
|
311
|
-
const body = await fetchIssueBody({ repo, issue }, { env, ghCommand });
|
|
422
|
+
const body = await fetchIssueBody({ repo, issue }, { env, ghCommand, runChild });
|
|
312
423
|
if (body === null) {
|
|
313
424
|
evaluated.push({ issue, artifact: null });
|
|
314
425
|
continue;
|
|
@@ -327,6 +438,7 @@ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed,
|
|
|
327
438
|
status: "present",
|
|
328
439
|
linkedIssue: firstPresent.issue,
|
|
329
440
|
linkedIssues,
|
|
441
|
+
specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.LINKED_ISSUE,
|
|
330
442
|
refinedIssues,
|
|
331
443
|
source: a.source,
|
|
332
444
|
acItems: a.acItems,
|
|
@@ -354,6 +466,7 @@ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed,
|
|
|
354
466
|
status: "missing",
|
|
355
467
|
linkedIssue: firstEvaluated.issue,
|
|
356
468
|
linkedIssues,
|
|
469
|
+
specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.LINKED_ISSUE,
|
|
357
470
|
refinedIssues,
|
|
358
471
|
reason: `Failed to fetch body for linked issue(s) ${scopeLabel}; draft gate cannot verify a refinement artifact, treating as missing.`,
|
|
359
472
|
finding: "missing_refinement_artifact",
|
|
@@ -377,6 +490,7 @@ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed,
|
|
|
377
490
|
status: "missing",
|
|
378
491
|
linkedIssue: firstFetched.issue,
|
|
379
492
|
linkedIssues,
|
|
493
|
+
specSource: REFINEMENT_ARTIFACT_SPEC_SOURCE.LINKED_ISSUE,
|
|
380
494
|
refinedIssues,
|
|
381
495
|
source: first.source,
|
|
382
496
|
acItems: first.acItems,
|
|
@@ -390,7 +504,7 @@ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed,
|
|
|
390
504
|
_onlyEnforcedWhenDraft: prDraft === true,
|
|
391
505
|
};
|
|
392
506
|
}
|
|
393
|
-
async function fetchLocalConflictFiles({ env = process.env, gitCommand = "git" } = {}) {
|
|
507
|
+
async function fetchLocalConflictFiles({ env = process.env, gitCommand = "git", runChild = defaultRunChild } = {}) {
|
|
394
508
|
let result;
|
|
395
509
|
try {
|
|
396
510
|
result = await runChild(
|
|
@@ -458,9 +572,20 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
|
458
572
|
// detect-copilot-loop-state path and with request-copilot-review's cap logic.
|
|
459
573
|
const interpreterRepoRoot = runtime.repoRoot ?? resolveRepoRoot(process.cwd());
|
|
460
574
|
const interpreterConfigResult = await loadDevLoopConfig({ repoRoot: interpreterRepoRoot });
|
|
461
|
-
const
|
|
575
|
+
const interpreterConfigHasErrors = Array.isArray(interpreterConfigResult.errors) && interpreterConfigResult.errors.length > 0;
|
|
576
|
+
const interpreterRefinementConfig = interpreterConfigHasErrors
|
|
462
577
|
? resolveRefinement({ version: 1 })
|
|
463
578
|
: resolveRefinement(interpreterConfigResult.config ?? { version: 1 });
|
|
579
|
+
if (options.lightweight) {
|
|
580
|
+
// Compose (not replace) the round cap for light-dispatched PRs (#1210):
|
|
581
|
+
// min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds), so
|
|
582
|
+
// maxCopilotRounds: 0 still disables Copilot rounds everywhere. Shared with
|
|
583
|
+
// the maxCopilotRounds resolution below (#1126 requires the two to agree).
|
|
584
|
+
interpreterRefinementConfig.maxCopilotRounds = resolveEffectiveCopilotRoundCap(
|
|
585
|
+
interpreterConfigHasErrors ? { version: 1 } : (interpreterConfigResult.config ?? { version: 1 }),
|
|
586
|
+
{ lightweight: true },
|
|
587
|
+
);
|
|
588
|
+
}
|
|
464
589
|
const interpretation = interpretLoopState(snapshot, interpreterRefinementConfig);
|
|
465
590
|
const disposition = summarizeLoopInterpretation(interpretation, interpreterRefinementConfig);
|
|
466
591
|
const mergeStateStatus = typeof prData?.mergeStateStatus === "string" && prData.mergeStateStatus.trim().length > 0
|
|
@@ -493,7 +618,7 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
|
493
618
|
};
|
|
494
619
|
}
|
|
495
620
|
|
|
496
|
-
async function fetchCopilotEverFormallyRequested({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
621
|
+
async function fetchCopilotEverFormallyRequested({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
497
622
|
const result = await runChild(
|
|
498
623
|
ghCommand,
|
|
499
624
|
["api", `repos/${repo}/issues/${pr}/timeline`, "--paginate", "--jq",
|
|
@@ -515,7 +640,12 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
515
640
|
const hasConfigErrors = Array.isArray(configLoadResult.errors) && configLoadResult.errors.length > 0;
|
|
516
641
|
const config = hasConfigErrors ? {} : (configLoadResult.config ?? {});
|
|
517
642
|
const draftGateConfig = resolveGateConfig(config, "draft");
|
|
518
|
-
|
|
643
|
+
// Shared with interpreterRefinementConfig.maxCopilotRounds in
|
|
644
|
+
// loadPrGateCoordinationContext (#1126: the two must never disagree at the
|
|
645
|
+
// cap boundary) — the same lightweight composition (#1210) is applied here.
|
|
646
|
+
const maxCopilotRounds = options.lightweight
|
|
647
|
+
? resolveEffectiveCopilotRoundCap(config, { lightweight: true })
|
|
648
|
+
: resolveRefinementConfig(config, "maxCopilotRounds");
|
|
519
649
|
// Shared with interpretLoopState (consumed by copilot-pr-handoff.mjs) and
|
|
520
650
|
// evaluatePrGateCoordination — the single source of truth for "is the
|
|
521
651
|
// Copilot round cap reached" so this detector cannot disagree with the
|
|
@@ -556,6 +686,10 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
556
686
|
copilotReviewRoundCount: context.snapshot?.copilotReviewRoundCount ?? 0,
|
|
557
687
|
maxCopilotRounds,
|
|
558
688
|
sameHeadCleanConverged: context.interpretation.sameHeadCleanConverged,
|
|
689
|
+
// Independent gate-ENTRY re-check (#1190): fed alongside (not derived from)
|
|
690
|
+
// sameHeadCleanConverged, so an outstanding request on the current head refuses
|
|
691
|
+
// RUN_PRE_APPROVAL_GATE even if sameHeadCleanConverged were somehow stale/wrong.
|
|
692
|
+
copilotReviewRequestStatus: context.snapshot?.copilotReviewRequestStatus ?? "none",
|
|
559
693
|
draftGateRequireCi: draftGateConfig.requireCi,
|
|
560
694
|
draftGate: context.gateEvidence.draftGate,
|
|
561
695
|
draftGateMarker: context.gateEvidence.draftGateMarker,
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { requireTokenValue } from "../_cli-primitives.mjs";
|
|
5
|
+
import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
6
|
+
import {
|
|
7
|
+
interpretRefinementGrillState,
|
|
8
|
+
normalizeGrillSnapshot,
|
|
9
|
+
} from "@dev-loops/core/loop/refinement-grill-state";
|
|
10
|
+
import { detectIssueRefinementArtifact } from "@dev-loops/core/loop/issue-refinement-artifact";
|
|
11
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
12
|
+
|
|
13
|
+
const HELP = `Usage: detect-refinement-grill-state.mjs [--input <path> | --body-file <path> [--surface issue|pr|plan]]
|
|
14
|
+
Detect refinement/grill sub-loop state.
|
|
15
|
+
Modes (choose exactly one):
|
|
16
|
+
--input <path> Interpret a JSON grill snapshot from file
|
|
17
|
+
--body-file <path> Seed the deterministic already-refined / zero-iteration
|
|
18
|
+
snapshot from a markdown body on disk
|
|
19
|
+
Options (body-file mode only):
|
|
20
|
+
--surface issue|pr|plan Surface the body belongs to (default: issue)
|
|
21
|
+
|
|
22
|
+
${JQ_OUTPUT_USAGE}
|
|
23
|
+
|
|
24
|
+
Exit codes:
|
|
25
|
+
0 Success
|
|
26
|
+
1 Error
|
|
27
|
+
2 Invalid --jq filter
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
const VALID_SURFACES = new Set(["issue", "pr", "plan"]);
|
|
31
|
+
|
|
32
|
+
export function parseDetectGrillCliArgs(argv) {
|
|
33
|
+
const { tokens } = parseArgs({
|
|
34
|
+
args: [...argv],
|
|
35
|
+
options: {
|
|
36
|
+
help: { type: "boolean", short: "h" },
|
|
37
|
+
input: { type: "string" },
|
|
38
|
+
"body-file": { type: "string" },
|
|
39
|
+
surface: { type: "string" },
|
|
40
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
41
|
+
},
|
|
42
|
+
allowPositionals: true,
|
|
43
|
+
strict: false,
|
|
44
|
+
tokens: true,
|
|
45
|
+
});
|
|
46
|
+
const options = {
|
|
47
|
+
inputPath: undefined,
|
|
48
|
+
bodyFilePath: undefined,
|
|
49
|
+
surface: undefined,
|
|
50
|
+
help: false,
|
|
51
|
+
};
|
|
52
|
+
for (const token of tokens) {
|
|
53
|
+
if (token.kind === "positional") {
|
|
54
|
+
throw new Error(`Unknown argument: ${token.value}`);
|
|
55
|
+
}
|
|
56
|
+
if (token.kind !== "option") {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (token.name === "help") {
|
|
60
|
+
options.help = true;
|
|
61
|
+
return options;
|
|
62
|
+
}
|
|
63
|
+
if (token.name === "input") {
|
|
64
|
+
options.inputPath = requireTokenValue(token);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (token.name === "body-file") {
|
|
68
|
+
options.bodyFilePath = requireTokenValue(token);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (token.name === "surface") {
|
|
72
|
+
const surface = requireTokenValue(token).trim();
|
|
73
|
+
if (!VALID_SURFACES.has(surface)) {
|
|
74
|
+
throw new Error("--surface must be one of: issue, pr, plan");
|
|
75
|
+
}
|
|
76
|
+
options.surface = surface;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t))) continue;
|
|
80
|
+
throw new Error(`Unknown argument: ${token.rawName}`);
|
|
81
|
+
}
|
|
82
|
+
const hasInput = options.inputPath !== undefined;
|
|
83
|
+
const hasBodyFile = options.bodyFilePath !== undefined;
|
|
84
|
+
if (hasInput === hasBodyFile) {
|
|
85
|
+
throw new Error("Provide exactly one input source: --input <path> or --body-file <path>");
|
|
86
|
+
}
|
|
87
|
+
if (hasInput && options.surface !== undefined) {
|
|
88
|
+
throw new Error("--surface applies only to --body-file mode");
|
|
89
|
+
}
|
|
90
|
+
return options;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function runCli(
|
|
94
|
+
argv = process.argv.slice(2),
|
|
95
|
+
{ stdout = process.stdout, stderr = process.stderr } = {},
|
|
96
|
+
) {
|
|
97
|
+
const options = parseDetectGrillCliArgs(argv);
|
|
98
|
+
if (options.help) { stdout.write(HELP); return; }
|
|
99
|
+
|
|
100
|
+
let snapshot;
|
|
101
|
+
if (options.inputPath) {
|
|
102
|
+
const text = await readFile(options.inputPath, "utf8");
|
|
103
|
+
snapshot = normalizeGrillSnapshot(parseJsonText(text));
|
|
104
|
+
} else {
|
|
105
|
+
const body = await readFile(options.bodyFilePath, "utf8");
|
|
106
|
+
const surface = options.surface ?? "issue";
|
|
107
|
+
// Deterministic seed for the already-refined / zero-iteration path only: the full
|
|
108
|
+
// semantic gap detection (scope/actor/decision) is the agent-layer bounded input consumed
|
|
109
|
+
// at await_answers. This body-file mode computes ONLY the deterministic AC-presence signal
|
|
110
|
+
// via the single is-it-refined source of truth, detectIssueRefinementArtifact.
|
|
111
|
+
const artifact = detectIssueRefinementArtifact({ body });
|
|
112
|
+
snapshot = normalizeGrillSnapshot({
|
|
113
|
+
loaded: true,
|
|
114
|
+
detectRan: true,
|
|
115
|
+
surface,
|
|
116
|
+
openGapCount: artifact.finding ? 1 : 0,
|
|
117
|
+
unresolvedGapCount: 0,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const interpretation = interpretRefinementGrillState(snapshot);
|
|
122
|
+
process.exitCode = emitResult(
|
|
123
|
+
{
|
|
124
|
+
ok: true,
|
|
125
|
+
snapshot,
|
|
126
|
+
state: interpretation.state,
|
|
127
|
+
allowedTransitions: interpretation.allowedTransitions,
|
|
128
|
+
nextAction: interpretation.nextAction,
|
|
129
|
+
},
|
|
130
|
+
{ jq: options.jq, silent: options.silent, stdout, stderr },
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
135
|
+
runCli().catch((error) => { process.stderr.write(`${formatCliError(error)}\n`); process.exitCode = 1; });
|
|
136
|
+
}
|
|
@@ -21,7 +21,8 @@ import {
|
|
|
21
21
|
} from "@dev-loops/core/loop/async-start-contract";
|
|
22
22
|
import { detectRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
23
23
|
import { isCopilotLogin } from "@dev-loops/core/github/copilot-helpers";
|
|
24
|
-
import { loadDevLoopConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
|
|
24
|
+
import { loadDevLoopConfig, resolveLightMode, resolveWorkflowConfig } from "@dev-loops/core/config";
|
|
25
|
+
import { detectScope } from "./detect-change-scope.mjs";
|
|
25
26
|
import { createPiAdapter } from "@dev-loops/core/harness";
|
|
26
27
|
import { validatePlanFile } from "../refine/validate-plan-file.mjs";
|
|
27
28
|
import {
|
|
@@ -65,6 +66,14 @@ Optional modifier:
|
|
|
65
66
|
heuristic (chore/fix commit type + no --plan-file + small change)
|
|
66
67
|
is a documented manual signal; --lightweight is the explicit,
|
|
67
68
|
deterministic trigger.
|
|
69
|
+
Used ALONE (no --issue/--pr/--input/--plan-file/--spike):
|
|
70
|
+
issue-less PR-first (#1210) — no tracker binding at all
|
|
71
|
+
(canonicalSpecSource: pr_body, no issue-keyed worktree
|
|
72
|
+
requirement). Gated on localImplementation.lightMode being
|
|
73
|
+
enabled AND the live change scope (git diff) staying within
|
|
74
|
+
its maxFiles/maxLines threshold; fails closed with a distinct
|
|
75
|
+
reason (light mode disabled / scope undetectable / over
|
|
76
|
+
threshold) requiring --issue above the threshold.
|
|
68
77
|
${JQ_OUTPUT_USAGE}
|
|
69
78
|
|
|
70
79
|
Exit codes:
|
|
@@ -200,17 +209,23 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
|
|
|
200
209
|
if (modeCount > 1) {
|
|
201
210
|
throw parseError("--issue, --pr, --input, --plan-file, and --spike are mutually exclusive; provide exactly one");
|
|
202
211
|
}
|
|
203
|
-
|
|
204
|
-
|
|
212
|
+
// --lightweight is normally a MODIFIER (not a 6th mode): it makes the PR body
|
|
213
|
+
// the spec-of-record for the --issue local path. Used ALONE (modeCount === 0,
|
|
214
|
+
// issue #1210) it is instead the issue-less PR-first trigger — no tracker
|
|
215
|
+
// binding at all — so the "no mode selected" error is skipped in that case.
|
|
216
|
+
if (modeCount === 0 && !options.lightweight) {
|
|
217
|
+
throw parseError("--input <path>, --issue <n>, --pr <n>, --plan-file <path>, --spike <path>, or --lightweight (issue-less PR-first) is required");
|
|
205
218
|
}
|
|
206
|
-
// --lightweight is
|
|
207
|
-
//
|
|
208
|
-
// (which commits a durable plan doc as the spec) and only composes with --issue.
|
|
219
|
+
// --lightweight is the opposite of --plan-file (which commits a durable plan
|
|
220
|
+
// doc as the spec) regardless of mode.
|
|
209
221
|
if (options.lightweight && options.planFile !== undefined) {
|
|
210
222
|
throw parseError("--lightweight and --plan-file are opposites: --plan-file commits a durable plan doc as the spec-of-record, --lightweight makes the PR body the spec. Provide only one.");
|
|
211
223
|
}
|
|
212
|
-
|
|
213
|
-
|
|
224
|
+
// When another mode IS selected, --lightweight only composes with --issue
|
|
225
|
+
// (not --pr/--input/--spike). Used with no mode selected at all, it is the
|
|
226
|
+
// issue-less PR-first trigger handled above.
|
|
227
|
+
if (options.lightweight && modeCount > 0 && options.issue === undefined) {
|
|
228
|
+
throw parseError("--lightweight is a modifier for the --issue path (the PR body becomes the spec-of-record). Combine it with --issue <n>, or use --lightweight alone (no other mode flag) for the issue-less PR-first path.");
|
|
214
229
|
}
|
|
215
230
|
return options;
|
|
216
231
|
}
|
|
@@ -545,6 +560,120 @@ export function buildSpikeInput({ spikeFilePath }) {
|
|
|
545
560
|
},
|
|
546
561
|
};
|
|
547
562
|
}
|
|
563
|
+
// Candidate default-branch refs for issue-less scope measurement, in preference
|
|
564
|
+
// order. origin/HEAD tracks the remote default branch when set; the rest cover
|
|
565
|
+
// the common names.
|
|
566
|
+
const ISSUELESS_BASE_REF_CANDIDATES = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
|
|
567
|
+
|
|
568
|
+
function resolveIssuelessMergeBase(cwd) {
|
|
569
|
+
for (const ref of ISSUELESS_BASE_REF_CANDIDATES) {
|
|
570
|
+
try {
|
|
571
|
+
return execFileSync("git", ["merge-base", ref, "HEAD"], {
|
|
572
|
+
cwd,
|
|
573
|
+
encoding: "utf8",
|
|
574
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
575
|
+
}).trim();
|
|
576
|
+
} catch {
|
|
577
|
+
// try the next candidate
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Decide whether the live change scope is eligible for issue-less lightweight
|
|
585
|
+
* PR-first (#1210): reuses the same localImplementation.lightMode threshold
|
|
586
|
+
* that gates inline vs full-fanout gate dispatch, so "genuinely small" means
|
|
587
|
+
* the same thing everywhere in the repo.
|
|
588
|
+
*
|
|
589
|
+
* Scope is measured from the merge-base with the default branch to the WORKING
|
|
590
|
+
* TREE (`git diff --stat <merge-base>`), so multi-commit branches and
|
|
591
|
+
* uncommitted changes are both counted — a HEAD~1-only measure would fail OPEN
|
|
592
|
+
* on a branch whose earlier commits already exceed the threshold. Fails CLOSED
|
|
593
|
+
* on every negative path (disabled / no resolvable base / undetectable diff /
|
|
594
|
+
* over threshold) with a distinct reason so the caller can report why --issue
|
|
595
|
+
* is required instead of silently defaulting one way or the other.
|
|
596
|
+
*
|
|
597
|
+
* Every git invocation is bound to `cwd` (the adapter-resolved repoRoot),
|
|
598
|
+
* matching the rest of this file's git calls — process.cwd() is never relied
|
|
599
|
+
* on implicitly so this stays correct when invoked from a subdirectory or
|
|
600
|
+
* with a harness cwd that differs from the OS process cwd.
|
|
601
|
+
*
|
|
602
|
+
* @param {import("@dev-loops/core/config").DevLoopConfig} config
|
|
603
|
+
* @param {string} [cwd] - Repo root to run git commands in (defaults to process.cwd() when omitted).
|
|
604
|
+
* @returns {{ eligible: true, scope: object, threshold: {maxFiles:number,maxLines:number} } | { eligible: false, reason: "light_mode_disabled"|"scope_detection_failed"|"over_threshold", scope?: object, threshold?: object, detail?: string }}
|
|
605
|
+
*/
|
|
606
|
+
export function resolveIssuelessLightweightEligibility(config, cwd) {
|
|
607
|
+
const threshold = resolveLightMode(config);
|
|
608
|
+
if (!threshold) {
|
|
609
|
+
return { eligible: false, reason: "light_mode_disabled" };
|
|
610
|
+
}
|
|
611
|
+
const mergeBase = resolveIssuelessMergeBase(cwd);
|
|
612
|
+
if (mergeBase === null) {
|
|
613
|
+
return {
|
|
614
|
+
eligible: false,
|
|
615
|
+
reason: "scope_detection_failed",
|
|
616
|
+
detail: `no merge-base with any default-branch candidate (${ISSUELESS_BASE_REF_CANDIDATES.join(", ")})`,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
// detectScope with only `base` diffs base..working-tree in one measure
|
|
620
|
+
// (committed branch delta + uncommitted changes).
|
|
621
|
+
const scope = detectScope({ base: mergeBase, cwd });
|
|
622
|
+
if (scope.ok === false) {
|
|
623
|
+
return { eligible: false, reason: "scope_detection_failed", detail: scope.error };
|
|
624
|
+
}
|
|
625
|
+
if (scope.filesChanged > threshold.maxFiles || scope.linesChanged > threshold.maxLines) {
|
|
626
|
+
return { eligible: false, reason: "over_threshold", scope, threshold };
|
|
627
|
+
}
|
|
628
|
+
return { eligible: true, scope, threshold };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Build a `--lightweight` startup input with NO tracker binding at all
|
|
633
|
+
* (issue-less PR-first, #1210): `--lightweight` used alone, no --issue.
|
|
634
|
+
*
|
|
635
|
+
* Read-only: no tracker mutation, no GitHub calls, no issue/PR number. Gated
|
|
636
|
+
* by {@link resolveIssuelessLightweightEligibility} — an ineligible change
|
|
637
|
+
* throws so the CLI fails closed (exit 1, no readiness bundle) with a message
|
|
638
|
+
* naming the distinct reason, mirroring buildPlanFileInput/buildSpikeInput's
|
|
639
|
+
* fail-closed-on-invalid-input convention. Exempt from the worktree-isolation
|
|
640
|
+
* guard like the plan-file/spike paths: there is no issue number to key a
|
|
641
|
+
* worktree on.
|
|
642
|
+
*
|
|
643
|
+
* @param {{ config: import("@dev-loops/core/config").DevLoopConfig, cwd?: string }} params
|
|
644
|
+
* @returns {object} startup input with canonicalSpecSource: "pr_body"
|
|
645
|
+
*/
|
|
646
|
+
export function buildLightweightIssuelessInput({ config, cwd }) {
|
|
647
|
+
const eligibility = resolveIssuelessLightweightEligibility(config, cwd);
|
|
648
|
+
if (!eligibility.eligible) {
|
|
649
|
+
if (eligibility.reason === "light_mode_disabled") {
|
|
650
|
+
throw new Error("--lightweight without --issue (issue-less PR-first) requires localImplementation.lightMode.enabled in .devloops; enable light mode or provide --issue <n>.");
|
|
651
|
+
}
|
|
652
|
+
if (eligibility.reason === "scope_detection_failed") {
|
|
653
|
+
throw new Error(`--lightweight without --issue (issue-less PR-first) requires a measurable change scope; git diff failed (${eligibility.detail}). Provide --issue <n> instead.`);
|
|
654
|
+
}
|
|
655
|
+
throw new Error(`--lightweight without --issue (issue-less PR-first) requires the change to stay within the light-mode threshold (maxFiles=${eligibility.threshold.maxFiles}, maxLines=${eligibility.threshold.maxLines}); this change is ${eligibility.scope.filesChanged} files / ${eligibility.scope.linesChanged} lines. Provide --issue <n> for above-threshold changes.`);
|
|
656
|
+
}
|
|
657
|
+
return {
|
|
658
|
+
intent: "start_issue_locally",
|
|
659
|
+
mode: "bounded_handoff",
|
|
660
|
+
targetPreference: "prefer_local",
|
|
661
|
+
artifactState: "not_applicable",
|
|
662
|
+
issueLinkageResolution: "not_applicable",
|
|
663
|
+
issueReadiness: "not_applicable",
|
|
664
|
+
issueAssignmentState: "not_applicable",
|
|
665
|
+
loopState: "implementation_pending",
|
|
666
|
+
canonicalSpecSource: "pr_body",
|
|
667
|
+
planFileExempt: true,
|
|
668
|
+
currentState: {
|
|
669
|
+
target: { kind: "local_phase", issue: null, pr: null, linkedPr: null, branch: null, phase: "lightweight-issueless" },
|
|
670
|
+
ownership: "local",
|
|
671
|
+
nextActor: "local",
|
|
672
|
+
status: "active",
|
|
673
|
+
authorization: "authorized",
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
}
|
|
548
677
|
export function summarizeCanonicalState(bundle) {
|
|
549
678
|
return {
|
|
550
679
|
target: bundle.canonicalState?.target ?? null,
|
|
@@ -746,6 +875,14 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
|
|
|
746
875
|
if (options.lightweight) {
|
|
747
876
|
input = { ...input, canonicalSpecSource: "pr_body" };
|
|
748
877
|
}
|
|
878
|
+
} else if (options.lightweight) {
|
|
879
|
+
// --lightweight used ALONE (no other mode flag): issue-less PR-first (#1210).
|
|
880
|
+
// A broken config must surface as ITS OWN failure, not decay to the
|
|
881
|
+
// misleading light_mode_disabled reason a bare {version:1} would produce.
|
|
882
|
+
if (configErrors.length > 0) {
|
|
883
|
+
throw new Error(`--lightweight without --issue (issue-less PR-first) requires a loadable dev-loop config, but config loading failed: ${configErrors.map((e) => e?.message ?? String(e)).join("; ")}. Fix the config or provide --issue <n>.`);
|
|
884
|
+
}
|
|
885
|
+
input = buildLightweightIssuelessInput({ config: devLoopConfig, cwd: repoRoot });
|
|
749
886
|
} else {
|
|
750
887
|
input = buildAutoResolvedInput({
|
|
751
888
|
pr: options.pr,
|