dev-loops 0.7.1 → 0.7.2
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 +2 -2
- package/.claude/agents/review.md +2 -2
- package/.claude/commands/loop-enqueue.md +1 -1
- package/.claude/commands/loop-info.md +1 -1
- package/.claude/commands/loop-start-spike.md +1 -1
- package/.claude/skills/copilot-pr-followup/SKILL.md +37 -23
- package/.claude/skills/dev-loop/SKILL.md +6 -6
- package/.claude/skills/docs/acceptance-criteria-verification.md +3 -2
- package/.claude/skills/docs/anti-patterns.md +3 -2
- package/.claude/skills/docs/artifact-authority-contract.md +12 -11
- package/.claude/skills/docs/confirmation-rules.md +1 -0
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +18 -14
- 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 +25 -19
- package/.claude/skills/docs/merge-preconditions.md +13 -11
- package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
- package/.claude/skills/docs/public-dev-loop-contract.md +59 -43
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +18 -5
- 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 +48 -142
- package/CHANGELOG.md +6 -0
- package/agents/dev-loop.agent.md +8 -8
- package/agents/fixer.agent.md +1 -0
- package/agents/refiner.agent.md +2 -2
- package/agents/review.agent.md +2 -2
- package/package.json +5 -4
- 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 +1092 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +102 -17
- package/scripts/github/list-review-threads.mjs +277 -0
- package/scripts/github/post-gate-findings.mjs +6 -3
- package/scripts/github/request-copilot-review.mjs +92 -26
- package/scripts/github/upsert-checkpoint-verdict.mjs +74 -5
- package/scripts/github/verify-briefing-prefixes.mjs +191 -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 +213 -7
- package/scripts/github/write-gate-findings-log.mjs +54 -2
- package/scripts/loop/copilot-pr-handoff.mjs +29 -2
- package/scripts/loop/detect-change-scope.mjs +2 -2
- package/scripts/loop/detect-copilot-loop-state.mjs +23 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -3
- package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
- package/scripts/loop/sanctioned-commands.mjs +2 -0
- package/scripts/loop/validate-pr-body-spec.mjs +21 -5
- package/scripts/pages/build-state-atlas.mjs +50 -48
- package/scripts/projects/_resolve-project.mjs +1 -148
- 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 +37 -23
- package/skills/dev-loop/SKILL.md +1 -1
- package/skills/docs/acceptance-criteria-verification.md +3 -2
- package/skills/docs/anti-patterns.md +3 -2
- package/skills/docs/artifact-authority-contract.md +12 -11
- package/skills/docs/confirmation-rules.md +1 -0
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +18 -14
- 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 +25 -19
- package/skills/docs/merge-preconditions.md +13 -11
- package/skills/docs/pr-lifecycle-contract.md +44 -35
- package/skills/docs/public-dev-loop-contract.md +59 -43
- package/skills/docs/required-rules.json +149 -0
- package/skills/docs/retrospective-checkpoint-contract.md +18 -5
- 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 +48 -142
- package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
summarizeCopilotReviews,
|
|
12
12
|
} from "../_core-helpers.mjs";
|
|
13
13
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
14
|
-
import { loadDevLoopConfig, resolveGateConfig, resolveRefinement, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
|
|
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
17
|
import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
|
|
@@ -30,6 +30,12 @@ Required:
|
|
|
30
30
|
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
31
31
|
--pr <number> Pull request number
|
|
32
32
|
Optional:
|
|
33
|
+
--lightweight This PR is light-dispatched (#1210): compose the Copilot
|
|
34
|
+
round cap with localImplementation.lightMode.maxCopilotRounds
|
|
35
|
+
(default 1) via min(lightMode.maxCopilotRounds,
|
|
36
|
+
refinement.maxCopilotRounds) instead of using
|
|
37
|
+
refinement.maxCopilotRounds alone. refinement.maxCopilotRounds:
|
|
38
|
+
0 still disables Copilot rounds even with --lightweight.
|
|
33
39
|
Output (stdout, JSON):
|
|
34
40
|
{
|
|
35
41
|
"ok": true,
|
|
@@ -80,6 +86,7 @@ export function parseDetectPrGateCoordinationCliArgs(argv) {
|
|
|
80
86
|
help: false,
|
|
81
87
|
repo: undefined,
|
|
82
88
|
pr: undefined,
|
|
89
|
+
lightweight: false,
|
|
83
90
|
};
|
|
84
91
|
const { tokens } = parseArgs({
|
|
85
92
|
args: [...argv],
|
|
@@ -87,6 +94,7 @@ export function parseDetectPrGateCoordinationCliArgs(argv) {
|
|
|
87
94
|
help: { type: "boolean", short: "h" },
|
|
88
95
|
repo: { type: "string" },
|
|
89
96
|
pr: { type: "string" },
|
|
97
|
+
lightweight: { type: "boolean" },
|
|
90
98
|
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
91
99
|
},
|
|
92
100
|
allowPositionals: true,
|
|
@@ -112,6 +120,10 @@ export function parseDetectPrGateCoordinationCliArgs(argv) {
|
|
|
112
120
|
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
113
121
|
continue;
|
|
114
122
|
}
|
|
123
|
+
if (token.name === "lightweight") {
|
|
124
|
+
options.lightweight = true;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
115
127
|
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
116
128
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
117
129
|
}
|
|
@@ -458,9 +470,20 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
|
458
470
|
// detect-copilot-loop-state path and with request-copilot-review's cap logic.
|
|
459
471
|
const interpreterRepoRoot = runtime.repoRoot ?? resolveRepoRoot(process.cwd());
|
|
460
472
|
const interpreterConfigResult = await loadDevLoopConfig({ repoRoot: interpreterRepoRoot });
|
|
461
|
-
const
|
|
473
|
+
const interpreterConfigHasErrors = Array.isArray(interpreterConfigResult.errors) && interpreterConfigResult.errors.length > 0;
|
|
474
|
+
const interpreterRefinementConfig = interpreterConfigHasErrors
|
|
462
475
|
? resolveRefinement({ version: 1 })
|
|
463
476
|
: resolveRefinement(interpreterConfigResult.config ?? { version: 1 });
|
|
477
|
+
if (options.lightweight) {
|
|
478
|
+
// Compose (not replace) the round cap for light-dispatched PRs (#1210):
|
|
479
|
+
// min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds), so
|
|
480
|
+
// maxCopilotRounds: 0 still disables Copilot rounds everywhere. Shared with
|
|
481
|
+
// the maxCopilotRounds resolution below (#1126 requires the two to agree).
|
|
482
|
+
interpreterRefinementConfig.maxCopilotRounds = resolveEffectiveCopilotRoundCap(
|
|
483
|
+
interpreterConfigHasErrors ? { version: 1 } : (interpreterConfigResult.config ?? { version: 1 }),
|
|
484
|
+
{ lightweight: true },
|
|
485
|
+
);
|
|
486
|
+
}
|
|
464
487
|
const interpretation = interpretLoopState(snapshot, interpreterRefinementConfig);
|
|
465
488
|
const disposition = summarizeLoopInterpretation(interpretation, interpreterRefinementConfig);
|
|
466
489
|
const mergeStateStatus = typeof prData?.mergeStateStatus === "string" && prData.mergeStateStatus.trim().length > 0
|
|
@@ -515,7 +538,12 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
515
538
|
const hasConfigErrors = Array.isArray(configLoadResult.errors) && configLoadResult.errors.length > 0;
|
|
516
539
|
const config = hasConfigErrors ? {} : (configLoadResult.config ?? {});
|
|
517
540
|
const draftGateConfig = resolveGateConfig(config, "draft");
|
|
518
|
-
|
|
541
|
+
// Shared with interpreterRefinementConfig.maxCopilotRounds in
|
|
542
|
+
// loadPrGateCoordinationContext (#1126: the two must never disagree at the
|
|
543
|
+
// cap boundary) — the same lightweight composition (#1210) is applied here.
|
|
544
|
+
const maxCopilotRounds = options.lightweight
|
|
545
|
+
? resolveEffectiveCopilotRoundCap(config, { lightweight: true })
|
|
546
|
+
: resolveRefinementConfig(config, "maxCopilotRounds");
|
|
519
547
|
// Shared with interpretLoopState (consumed by copilot-pr-handoff.mjs) and
|
|
520
548
|
// evaluatePrGateCoordination — the single source of truth for "is the
|
|
521
549
|
// Copilot round cap reached" so this detector cannot disagree with the
|
|
@@ -556,6 +584,10 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
556
584
|
copilotReviewRoundCount: context.snapshot?.copilotReviewRoundCount ?? 0,
|
|
557
585
|
maxCopilotRounds,
|
|
558
586
|
sameHeadCleanConverged: context.interpretation.sameHeadCleanConverged,
|
|
587
|
+
// Independent gate-ENTRY re-check (#1190): fed alongside (not derived from)
|
|
588
|
+
// sameHeadCleanConverged, so an outstanding request on the current head refuses
|
|
589
|
+
// RUN_PRE_APPROVAL_GATE even if sameHeadCleanConverged were somehow stale/wrong.
|
|
590
|
+
copilotReviewRequestStatus: context.snapshot?.copilotReviewRequestStatus ?? "none",
|
|
559
591
|
draftGateRequireCi: draftGateConfig.requireCi,
|
|
560
592
|
draftGate: context.gateEvidence.draftGate,
|
|
561
593
|
draftGateMarker: context.gateEvidence.draftGateMarker,
|
|
@@ -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,
|
|
@@ -26,9 +26,11 @@ export const SANCTIONED_COMMANDS = Object.freeze({
|
|
|
26
26
|
reads: Object.freeze({
|
|
27
27
|
"pr-facts": "scripts/github/view-pr.mjs",
|
|
28
28
|
"ci-status": "scripts/github/probe-ci-status.mjs",
|
|
29
|
+
"ci-wait": "scripts/github/wait-pr-checks.mjs",
|
|
29
30
|
"ci-logs": "scripts/github/fetch-ci-logs.mjs",
|
|
30
31
|
"issue-list": "scripts/github/list-issues.mjs",
|
|
31
32
|
"copilot-review-state": "scripts/github/probe-copilot-review.mjs",
|
|
33
|
+
"review-threads": "scripts/github/list-review-threads.mjs",
|
|
32
34
|
"gate-coordination": "scripts/loop/detect-pr-gate-coordination-state.mjs",
|
|
33
35
|
}),
|
|
34
36
|
|
|
@@ -24,8 +24,8 @@ const USAGE = `Usage:
|
|
|
24
24
|
validate-pr-body-spec.mjs --input <path> [--expected-issue <n>]
|
|
25
25
|
Validate that a PR body carries the lightweight spec-of-record invariants
|
|
26
26
|
(Objective/why, In scope, Explicit non-goals, testable Acceptance criteria,
|
|
27
|
-
Definition of done, Open questions/risks, and
|
|
28
|
-
reference such as \`Closes #123\`).
|
|
27
|
+
Definition of done, Open questions/risks, and — unless --no-issue is given —
|
|
28
|
+
a GitHub closing-keyword issue reference such as \`Closes #123\`).
|
|
29
29
|
Required (exactly one):
|
|
30
30
|
--repo <owner/name> --pr <number> Fetch the PR body via gh and validate it
|
|
31
31
|
--input <path> Path to a JSON file with { "body": "..." }
|
|
@@ -34,7 +34,14 @@ Required (exactly one):
|
|
|
34
34
|
Optional:
|
|
35
35
|
--expected-issue <n> Positive integer; the referenced closing
|
|
36
36
|
issue(s) must include this number, else
|
|
37
|
-
"closes_wrong_issue".
|
|
37
|
+
"closes_wrong_issue". Mutually exclusive
|
|
38
|
+
with --no-issue.
|
|
39
|
+
--no-issue Explicit issue-less mode (#1210): the PR
|
|
40
|
+
is the sole artifact, so a closing-issue
|
|
41
|
+
reference is FORBIDDEN rather than
|
|
42
|
+
required — one present fails closed under
|
|
43
|
+
"unexpected_closing_issue_reference".
|
|
44
|
+
Mutually exclusive with --expected-issue.
|
|
38
45
|
Success output (stdout, JSON):
|
|
39
46
|
{
|
|
40
47
|
"ok": true | false,
|
|
@@ -61,6 +68,7 @@ export function parseValidatePrBodySpecCliArgs(argv) {
|
|
|
61
68
|
pr: undefined,
|
|
62
69
|
input: undefined,
|
|
63
70
|
expectedIssue: undefined,
|
|
71
|
+
noIssue: false,
|
|
64
72
|
};
|
|
65
73
|
const { tokens } = parseArgs({
|
|
66
74
|
args: [...argv],
|
|
@@ -70,6 +78,7 @@ export function parseValidatePrBodySpecCliArgs(argv) {
|
|
|
70
78
|
pr: { type: "string" },
|
|
71
79
|
input: { type: "string" },
|
|
72
80
|
"expected-issue": { type: "string" },
|
|
81
|
+
"no-issue": { type: "boolean" },
|
|
73
82
|
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
74
83
|
},
|
|
75
84
|
allowPositionals: true,
|
|
@@ -111,12 +120,19 @@ export function parseValidatePrBodySpecCliArgs(argv) {
|
|
|
111
120
|
options.expectedIssue = Number(value);
|
|
112
121
|
continue;
|
|
113
122
|
}
|
|
123
|
+
if (token.name === "no-issue") {
|
|
124
|
+
options.noIssue = true;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
114
127
|
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
115
128
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
116
129
|
}
|
|
117
130
|
if (options.help) {
|
|
118
131
|
return options;
|
|
119
132
|
}
|
|
133
|
+
if (options.noIssue && Number.isInteger(options.expectedIssue)) {
|
|
134
|
+
throw parseError("--no-issue is mutually exclusive with --expected-issue; provide exactly one issue-linkage mode");
|
|
135
|
+
}
|
|
120
136
|
const hasInput = typeof options.input === "string" && options.input.length > 0;
|
|
121
137
|
const hasAnyRemote = (typeof options.repo === "string" && options.repo.length > 0) || Number.isInteger(options.pr);
|
|
122
138
|
const hasRemotePair = typeof options.repo === "string" && options.repo.length > 0 && Number.isInteger(options.pr);
|
|
@@ -163,12 +179,12 @@ export async function validatePrBodySpecFromOptions(options, { env = process.env
|
|
|
163
179
|
const body = typeof payload.body === "string" ? payload.body : "";
|
|
164
180
|
const repo = typeof payload.repo === "string" ? payload.repo : options.repo ?? null;
|
|
165
181
|
const pr = Number.isInteger(payload.pr) ? payload.pr : options.pr ?? null;
|
|
166
|
-
return { ...validatePrBodySpec({ body, expectedIssue: options.expectedIssue ?? null }), repo, pr };
|
|
182
|
+
return { ...validatePrBodySpec({ body, expectedIssue: options.expectedIssue ?? null, issueLess: options.noIssue }), repo, pr };
|
|
167
183
|
}
|
|
168
184
|
parseRepoSlug(options.repo);
|
|
169
185
|
const body = await fetchPrBody({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
170
186
|
return {
|
|
171
|
-
...validatePrBodySpec({ body, expectedIssue: options.expectedIssue ?? null }),
|
|
187
|
+
...validatePrBodySpec({ body, expectedIssue: options.expectedIssue ?? null, issueLess: options.noIssue }),
|
|
172
188
|
repo: options.repo,
|
|
173
189
|
pr: options.pr,
|
|
174
190
|
};
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// state machine rendered as a mermaid diagram, emitted DETERMINISTICALLY from
|
|
3
3
|
// the code's own exported tables so the page can never drift from the code.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
// reviewer loop, outer conductor routing,
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// Five diagrams come straight from packages/core/src/loop tables (copilot loop,
|
|
6
|
+
// reviewer loop, outer conductor routing, the public dev-loop gate hub, and the
|
|
7
|
+
// PR lifecycle contract); one more (the release pipeline workflow) is authored
|
|
8
|
+
// from a documented, table-less source.
|
|
9
9
|
//
|
|
10
10
|
// The page reuses the article design system (docs/articles/introducing-dev-loops.html):
|
|
11
11
|
// the same :root tokens, body gradient, and typography. It intentionally defines
|
|
@@ -15,6 +15,7 @@ import { STATE, TRANSITIONS } from '../../packages/core/src/loop/copilot-loop-st
|
|
|
15
15
|
import { REVIEWER_STATE, REVIEWER_TRANSITIONS } from '../../packages/core/src/loop/reviewer-loop-state.mjs';
|
|
16
16
|
import { OUTER_STATE, OUTER_TRANSITIONS } from '../../packages/core/src/loop/conductor-routing.mjs';
|
|
17
17
|
import { PUBLIC_DEV_LOOP_GATE_CONTRACT } from '../../packages/core/src/loop/public-dev-loop-routing-contract.mjs';
|
|
18
|
+
import { PR_LIFECYCLE_STATES, PR_LIFECYCLE_TRANSITIONS } from '../../packages/core/src/loop/pr-lifecycle.mjs';
|
|
18
19
|
|
|
19
20
|
// Classify a state/gate id by name into one of four visual classes. Colors are
|
|
20
21
|
// drawn from the site's own dark palette (accent violet, kicker blue,
|
|
@@ -80,7 +81,7 @@ function gateClass(row) {
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
function renderGateFlowchart(contract) {
|
|
83
|
-
const lines = ['flowchart
|
|
84
|
+
const lines = ['flowchart LR', ' router(["dev-loop router"])'];
|
|
84
85
|
for (const row of contract) {
|
|
85
86
|
const strategy = row.selectedStrategy ?? 'none';
|
|
86
87
|
lines.push(` router -->|${row.routeKind}| ${row.gate}["${row.gate} — ${strategy}"]`);
|
|
@@ -105,47 +106,16 @@ const outerDiagram = renderStateDiagram(
|
|
|
105
106
|
);
|
|
106
107
|
const gateDiagram = renderGateFlowchart(PUBLIC_DEV_LOOP_GATE_CONTRACT);
|
|
107
108
|
|
|
108
|
-
//
|
|
109
|
+
// PR lifecycle: the 13-state vocabulary + required transitions exported by
|
|
110
|
+
// packages/core/src/loop/pr-lifecycle.mjs (issue #1193), re-exported here so
|
|
111
|
+
// this module's public surface is unchanged. The L2/L3 state-machine
|
|
112
|
+
// conformance harness (scripts/docs/validate-state-machine-conformance.mjs)
|
|
113
|
+
// imports the same core module directly rather than through this one, so it
|
|
114
|
+
// never pulls in this page generator's diagram-rendering work at load time.
|
|
115
|
+
export { PR_LIFECYCLE_STATES, PR_LIFECYCLE_TRANSITIONS };
|
|
116
|
+
const prLifecycleDiagram = renderStateDiagram(PR_LIFECYCLE_TRANSITIONS, PR_LIFECYCLE_STATES);
|
|
109
117
|
|
|
110
|
-
//
|
|
111
|
-
// skills/docs/pr-lifecycle-contract.md.
|
|
112
|
-
const prLifecycleStates = [
|
|
113
|
-
'draft_local_review_gate',
|
|
114
|
-
'draft_local_remediation',
|
|
115
|
-
'ready_state_needs_copilot_request',
|
|
116
|
-
'waiting_for_copilot_review',
|
|
117
|
-
'copilot_feedback_remediation',
|
|
118
|
-
'copilot_reply_resolve_pending',
|
|
119
|
-
'merge_conflict_resolution',
|
|
120
|
-
'final_local_preapproval_gate',
|
|
121
|
-
'final_gate_remediation',
|
|
122
|
-
'waiting_for_human_pr_approval',
|
|
123
|
-
'waiting_for_merge',
|
|
124
|
-
'terminal_slice_complete',
|
|
125
|
-
'stopped_needs_user_decision',
|
|
126
|
-
];
|
|
127
|
-
const prLifecycleDiagram = renderStateDiagram([
|
|
128
|
-
['draft_local_review_gate', 'draft_local_remediation'],
|
|
129
|
-
['draft_local_review_gate', 'ready_state_needs_copilot_request'],
|
|
130
|
-
['draft_local_review_gate', 'stopped_needs_user_decision'],
|
|
131
|
-
['draft_local_remediation', 'draft_local_review_gate'],
|
|
132
|
-
['ready_state_needs_copilot_request', 'waiting_for_copilot_review'],
|
|
133
|
-
['ready_state_needs_copilot_request', 'stopped_needs_user_decision'],
|
|
134
|
-
['waiting_for_copilot_review', 'copilot_feedback_remediation'],
|
|
135
|
-
['copilot_feedback_remediation', 'copilot_reply_resolve_pending'],
|
|
136
|
-
['copilot_reply_resolve_pending', 'ready_state_needs_copilot_request'],
|
|
137
|
-
['waiting_for_copilot_review', 'merge_conflict_resolution'],
|
|
138
|
-
['merge_conflict_resolution', 'waiting_for_copilot_review'],
|
|
139
|
-
['waiting_for_copilot_review', 'final_local_preapproval_gate'],
|
|
140
|
-
['final_local_preapproval_gate', 'final_gate_remediation'],
|
|
141
|
-
['final_local_preapproval_gate', 'waiting_for_human_pr_approval'],
|
|
142
|
-
['final_gate_remediation', 'final_local_preapproval_gate'],
|
|
143
|
-
['waiting_for_human_pr_approval', 'waiting_for_merge'],
|
|
144
|
-
['waiting_for_human_pr_approval', 'draft_local_review_gate'],
|
|
145
|
-
['waiting_for_merge', 'terminal_slice_complete'],
|
|
146
|
-
['terminal_slice_complete', '[*]'],
|
|
147
|
-
['stopped_needs_user_decision', '[*]'],
|
|
148
|
-
], prLifecycleStates);
|
|
118
|
+
// --- Statically-authored diagram (documented, table-less source) ---
|
|
149
119
|
|
|
150
120
|
// Release pipeline: the fail-closed gate chain from .github/workflows/release.yml.
|
|
151
121
|
const releasePipelineNodes = [
|
|
@@ -189,7 +159,7 @@ const releaseDiagram = [
|
|
|
189
159
|
|
|
190
160
|
const INTRO_PROSE = [
|
|
191
161
|
'dev-loops drives every pull request through closed, deterministic state machines. Workflow control lives in the graph; agent judgment enters only as bounded, explicit inputs, never as hidden orchestration. Exactly one state applies at a time, and each state exposes the legal transitions out of it.',
|
|
192
|
-
"Every diagram on this page is generated at site-build time from the code's own exported tables — the copilot loop's <code>STATE</code>/<code>TRANSITIONS</code>, the reviewer loop's <code>REVIEWER_STATE</code>/<code>REVIEWER_TRANSITIONS</code>, the outer loop's <code>OUTER_STATE</code>/<code>OUTER_TRANSITIONS</code>,
|
|
162
|
+
"Every diagram on this page is generated at site-build time from the code's own exported tables — the copilot loop's <code>STATE</code>/<code>TRANSITIONS</code>, the reviewer loop's <code>REVIEWER_STATE</code>/<code>REVIEWER_TRANSITIONS</code>, the outer loop's <code>OUTER_STATE</code>/<code>OUTER_TRANSITIONS</code>, the public router's <code>PUBLIC_DEV_LOOP_GATE_CONTRACT</code>, and the PR lifecycle's <code>PR_LIFECYCLE_STATES</code>/<code>PR_LIFECYCLE_TRANSITIONS</code>. They cannot drift from the code: change a transition table and this page changes with it. One diagram (the release pipeline) is authored from a documented contract that has no single code table.",
|
|
193
163
|
'The state names are not labels for a picture — they are the literal contract identifiers that appear in logs, handoff envelopes, and gate artifacts. Nodes are coloured by role: <strong>active / in-progress</strong> steps the loop advances itself, <strong>waiting-on-external</strong> states that block on something outside the loop (CI, Copilot, a human), <strong>blocked / fail-closed</strong> states that stop or reconcile rather than guess, and <strong>terminal</strong> states where the slice is complete.',
|
|
194
164
|
];
|
|
195
165
|
|
|
@@ -217,7 +187,7 @@ const SECTIONS = [
|
|
|
217
187
|
{
|
|
218
188
|
id: 'pr-lifecycle',
|
|
219
189
|
title: 'PR lifecycle contract',
|
|
220
|
-
source: '
|
|
190
|
+
source: 'packages/core/src/loop/pr-lifecycle.mjs — PR_LIFECYCLE_STATES + PR_LIFECYCLE_TRANSITIONS',
|
|
221
191
|
prose: [
|
|
222
192
|
'One PR moves through a stable thirteen-state vocabulary from draft to merge. These identifiers are part of the contract surface even as the helper implementations around them change. Two local gates guard the path — <code>draft_gate</code> (draft to ready-for-review) and <code>pre_approval_gate</code> (before final approval) — and both are fail-closed fan-out reviews that run independent angle chains and must produce clean current-head evidence to pass.',
|
|
223
193
|
'All gate evidence is per-head: a new push re-opens the gates, and ready-to-draft resets the lifecycle back into draft-stage gating. A conflicted head detours through <code>merge_conflict_resolution</code> before any further gate progression — a conflicted PR is never treated as approval- or merge-ready, even if older gate comments and CI were green. Human approval and merge are explicit external waits, not hidden remediation states.',
|
|
@@ -270,7 +240,7 @@ function sectionMarkup(s) {
|
|
|
270
240
|
<h2>${s.title}</h2>
|
|
271
241
|
<p class="source">Diagram generated from <code>${s.source}</code></p>
|
|
272
242
|
${proseParagraphs(s.prose)}
|
|
273
|
-
<div class="diagram"><div class="mermaid">
|
|
243
|
+
<div class="diagram"><button class="expand" type="button" aria-label="View diagram fullscreen">⤢ Fullscreen</button><div class="mermaid">
|
|
274
244
|
${escapeMermaid(s.diagram)}
|
|
275
245
|
</div></div>
|
|
276
246
|
</section>`;
|
|
@@ -404,6 +374,16 @@ export function buildStateAtlasHtml() {
|
|
|
404
374
|
}
|
|
405
375
|
.diagram .mermaid { text-align: center; }
|
|
406
376
|
.diagram .mermaid svg { max-width: 100%; height: auto; }
|
|
377
|
+
.diagram { position: relative; }
|
|
378
|
+
.diagram .expand { position: sticky; float: right; top: 0.6rem; right: 0.6rem; margin: 0 0 -2rem; z-index: 2; font: 600 0.72rem var(--font); color: var(--copy); background: rgba(15, 23, 42, 0.85); border: 1px solid var(--card-border); border-radius: 8px; padding: 0.35rem 0.6rem; cursor: pointer; }
|
|
379
|
+
.diagram .expand:hover { color: var(--heading); border-color: var(--accent); }
|
|
380
|
+
.diagram .mermaid { cursor: zoom-in; }
|
|
381
|
+
.diagram .mermaid:fullscreen { cursor: zoom-out; background: #0b1220; display: flex; align-items: center; justify-content: center; padding: 2rem; }
|
|
382
|
+
.diagram .mermaid:-webkit-full-screen { cursor: zoom-out; background: #0b1220; display: flex; align-items: center; justify-content: center; padding: 2rem; }
|
|
383
|
+
.diagram .mermaid:-webkit-full-screen svg { max-width: calc(100vw - 4rem); max-height: calc(100vh - 4rem); width: auto; height: auto; }
|
|
384
|
+
.diagram .mermaid:fullscreen svg { max-width: calc(100vw - 4rem); max-height: calc(100vh - 4rem); width: auto; height: auto; }
|
|
385
|
+
.diagram .mermaid.fs-fallback { position: fixed; inset: 0; z-index: 50; background: #0b1220; display: flex; align-items: center; justify-content: center; padding: 1rem; cursor: zoom-out; overflow: auto; }
|
|
386
|
+
.diagram .mermaid.fs-fallback svg { max-width: calc(100vw - 2rem); max-height: calc(100vh - 2rem); width: auto; height: auto; }
|
|
407
387
|
</style>
|
|
408
388
|
</head>
|
|
409
389
|
<body>
|
|
@@ -416,6 +396,28 @@ ${sections}
|
|
|
416
396
|
</main>
|
|
417
397
|
<script src="assets/mermaid.min.js"></script>
|
|
418
398
|
<script>
|
|
399
|
+
let fsInFlight = false;
|
|
400
|
+
document.addEventListener('click', async (e) => {
|
|
401
|
+
if (fsInFlight) return;
|
|
402
|
+
const target = e.target instanceof Element ? e.target : e.target?.parentElement;
|
|
403
|
+
if (!target) return;
|
|
404
|
+
const btn = target.closest('.diagram .expand');
|
|
405
|
+
const box = btn ? btn.parentElement.querySelector('.mermaid') : target.closest('.diagram .mermaid');
|
|
406
|
+
if (!box) return;
|
|
407
|
+
fsInFlight = true;
|
|
408
|
+
try {
|
|
409
|
+
const fsElement = document.fullscreenElement || document.webkitFullscreenElement;
|
|
410
|
+
if (fsElement === box) {
|
|
411
|
+
await (document.exitFullscreen || document.webkitExitFullscreen)?.call(document);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (box.classList.contains('fs-fallback')) { box.classList.remove('fs-fallback'); return; }
|
|
415
|
+
const request = box.requestFullscreen || box.webkitRequestFullscreen;
|
|
416
|
+
if (request) await request.call(box);
|
|
417
|
+
else box.classList.add('fs-fallback'); // iOS Safari: CSS overlay fallback
|
|
418
|
+
} catch { /* fullscreen denied: fall back to the CSS overlay */ box.classList.add('fs-fallback'); }
|
|
419
|
+
finally { fsInFlight = false; }
|
|
420
|
+
});
|
|
419
421
|
mermaid.initialize({
|
|
420
422
|
startOnLoad: true,
|
|
421
423
|
securityLevel: 'strict',
|