dev-loops 0.4.0 → 0.6.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 +1 -1
- package/.claude/agents/refiner.md +1 -0
- package/.claude/commands/auto.md +7 -0
- package/.claude/commands/continue.md +15 -0
- package/.claude/commands/info.md +7 -0
- package/.claude/commands/start-spike.md +16 -0
- package/.claude/commands/start.md +7 -0
- package/.claude/commands/status.md +6 -0
- package/.claude/hooks/_run-context.mjs +11 -4
- package/.claude/skills/dev-loop/SKILL.md +21 -6
- package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/.claude/skills/docs/artifact-authority-contract.md +86 -31
- package/.claude/skills/docs/local-planning-flow.md +63 -0
- package/.claude/skills/docs/local-planning-worked-example.md +139 -0
- package/.claude/skills/docs/merge-preconditions.md +35 -0
- package/.claude/skills/docs/plan-file-contract.md +37 -0
- package/.claude/skills/docs/release-runbook.md +45 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/.claude/skills/docs/spike-mode-contract.md +237 -0
- package/.claude/skills/docs/ui-e2e-scoping-step.md +102 -0
- package/.claude/skills/local-implementation/SKILL.md +1 -1
- package/CHANGELOG.md +73 -0
- package/README.md +21 -1
- package/agents/dev-loop.agent.md +8 -1
- package/agents/refiner.agent.md +1 -0
- package/cli/index.mjs +2 -0
- package/extension/index.ts +10 -1
- package/extension/presentation.ts +15 -0
- package/lib/dev-loops-core.mjs +141 -0
- package/package.json +8 -3
- package/scripts/claude/generate-claude-assets.mjs +15 -1
- package/scripts/github/capture-review-threads.mjs +20 -2
- package/scripts/github/comment-issue.mjs +181 -0
- package/scripts/github/fetch-ci-logs.mjs +215 -0
- package/scripts/github/list-issues.mjs +191 -0
- package/scripts/github/probe-copilot-review.mjs +69 -3
- package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
- package/scripts/lib/jq-output.mjs +297 -0
- package/scripts/loop/_handoff-contract.mjs +1 -0
- package/scripts/loop/check-retro-tooling.mjs +246 -0
- package/scripts/loop/copilot-pr-handoff.mjs +21 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +65 -2
- package/scripts/loop/docs-grill-contract.mjs +70 -0
- package/scripts/loop/info.mjs +21 -2
- package/scripts/loop/pr-runner-coordination.mjs +20 -4
- package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
- package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
- package/scripts/loop/run-conductor-cycle.mjs +5 -0
- package/scripts/loop/run-watch-cycle.mjs +77 -3
- package/scripts/loop/slides-story-review-contract.mjs +123 -0
- package/scripts/pages/build-site.mjs +136 -0
- package/scripts/projects/add-queue-item.mjs +12 -2
- package/scripts/projects/list-queue-items.mjs +12 -2
- package/scripts/projects/move-queue-item.mjs +12 -2
- package/scripts/projects/resolve-active-board-item.mjs +193 -0
- package/scripts/refine/_refine-helpers.mjs +20 -0
- package/scripts/refine/exit-spike.mjs +186 -0
- package/scripts/refine/promote-plan.mjs +387 -0
- package/scripts/refine/refine-plan-file.mjs +165 -0
- package/scripts/refine/scaffold-spike-file.mjs +183 -0
- package/scripts/refine/validate-plan-file.mjs +64 -0
- package/scripts/refine/validate-spike-file.mjs +87 -0
- package/scripts/release/extract-changelog-section.mjs +111 -0
- package/skills/dev-loop/SKILL.md +24 -2
- package/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/skills/docs/artifact-authority-contract.md +86 -31
- package/skills/docs/local-planning-flow.md +63 -0
- package/skills/docs/local-planning-worked-example.md +139 -0
- package/skills/docs/merge-preconditions.md +35 -0
- package/skills/docs/plan-file-contract.md +37 -0
- package/skills/docs/release-runbook.md +45 -0
- package/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/skills/docs/spike-mode-contract.md +237 -0
- package/skills/docs/ui-e2e-scoping-step.md +102 -0
- package/skills/local-implementation/SKILL.md +1 -1
|
@@ -17,6 +17,7 @@ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
|
17
17
|
import { buildSnapshotFromPrFacts, interpretLoopState, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
|
|
18
18
|
import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
|
|
19
19
|
import { shouldGuardCopilotReviewRequest } from "@dev-loops/core/loop/pr-gate-coordination";
|
|
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 { detectCheckpointEvidence } from "../github/detect-checkpoint-evidence.mjs";
|
|
22
23
|
import { parseArgs } from "node:util";
|
|
@@ -163,7 +164,7 @@ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghComm
|
|
|
163
164
|
async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
164
165
|
const result = await runChild(
|
|
165
166
|
ghCommand,
|
|
166
|
-
["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeStateStatus,body,title,closingIssuesReferences,reviews,statusCheckRollup"],
|
|
167
|
+
["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeable,mergeStateStatus,body,title,closingIssuesReferences,reviews,statusCheckRollup,files"],
|
|
167
168
|
env,
|
|
168
169
|
);
|
|
169
170
|
if (result.code !== 0) {
|
|
@@ -172,6 +173,58 @@ async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh"
|
|
|
172
173
|
}
|
|
173
174
|
return parseJsonText(result.stdout, { label: "gh pr view" });
|
|
174
175
|
}
|
|
176
|
+
|
|
177
|
+
// GitHub computes `mergeable` asynchronously, so a freshly-pushed head briefly
|
|
178
|
+
// reads `UNKNOWN`. After the initial fetch, re-poll up to `maxPolls` more times
|
|
179
|
+
// while the value stays UNKNOWN (so at most 1 + maxPolls total fetches) before
|
|
180
|
+
// deciding; never treat a transient UNKNOWN as a pass — the caller fails closed
|
|
181
|
+
// to recheck if it never settles. (issue #980)
|
|
182
|
+
export async function fetchPrFactsWithSettledMergeable(
|
|
183
|
+
options,
|
|
184
|
+
{
|
|
185
|
+
env = process.env,
|
|
186
|
+
ghCommand = "gh",
|
|
187
|
+
maxPolls = 3,
|
|
188
|
+
pollDelayMs = 1500,
|
|
189
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
190
|
+
fetch = fetchPrFacts,
|
|
191
|
+
} = {},
|
|
192
|
+
) {
|
|
193
|
+
let prData = await fetch(options, { env, ghCommand });
|
|
194
|
+
let polls = 0;
|
|
195
|
+
while (String(prData?.mergeable || "").toUpperCase() === "UNKNOWN" && polls < maxPolls) {
|
|
196
|
+
polls += 1;
|
|
197
|
+
await sleep(pollDelayMs);
|
|
198
|
+
prData = await fetch(options, { env, ghCommand });
|
|
199
|
+
}
|
|
200
|
+
return prData;
|
|
201
|
+
}
|
|
202
|
+
// Changed-file paths from `gh pr view --json files` (issue #976). Feeds the
|
|
203
|
+
// path-triggered UI e2e scoping precondition in the evaluator.
|
|
204
|
+
export function extractChangedFiles(prData) {
|
|
205
|
+
const files = Array.isArray(prData?.files) ? prData.files : [];
|
|
206
|
+
return files
|
|
207
|
+
.map((entry) => (typeof entry?.path === "string" ? entry.path : null))
|
|
208
|
+
.filter((p) => typeof p === "string" && p.length > 0);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Whether the shared UI e2e suite passed for this head, read deterministically
|
|
212
|
+
// from the statusCheckRollup: every UI e2e check that is present must be
|
|
213
|
+
// SUCCESS. Returns null when no UI e2e check is present in the rollup (unknown
|
|
214
|
+
// → the evaluator fails closed), false if any present UI e2e check is not a
|
|
215
|
+
// success, true if all present ones succeeded.
|
|
216
|
+
export function deriveUiE2ePassed(prData, checkNames = UI_E2E_CHECK_NAMES) {
|
|
217
|
+
const rollup = Array.isArray(prData?.statusCheckRollup) ? prData.statusCheckRollup : [];
|
|
218
|
+
const wanted = new Set(checkNames);
|
|
219
|
+
const present = rollup.filter((entry) => wanted.has(entry?.name) || wanted.has(entry?.context));
|
|
220
|
+
if (present.length === 0) return null;
|
|
221
|
+
return present.every((entry) => {
|
|
222
|
+
const conclusion = String(entry?.conclusion ?? "").toUpperCase();
|
|
223
|
+
const state = String(entry?.state ?? "").toUpperCase();
|
|
224
|
+
return conclusion === "SUCCESS" || state === "SUCCESS";
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
175
228
|
export function resolveLinkedIssueFromPr(prData) {
|
|
176
229
|
if (!prData || typeof prData !== "object") return null;
|
|
177
230
|
const closing = Array.isArray(prData.closingIssuesReferences) ? prData.closingIssuesReferences : [];
|
|
@@ -290,7 +343,7 @@ async function loadRetrospectiveCheckpoint(repoRoot) {
|
|
|
290
343
|
}
|
|
291
344
|
}
|
|
292
345
|
export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
293
|
-
const prData = await
|
|
346
|
+
const prData = await fetchPrFactsWithSettledMergeable(options, runtime);
|
|
294
347
|
const currentHeadSha = typeof prData?.headRefOid === "string" && prData.headRefOid.trim().length > 0
|
|
295
348
|
? prData.headRefOid.trim()
|
|
296
349
|
: null;
|
|
@@ -349,6 +402,9 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
|
349
402
|
const mergeStateStatus = typeof prData?.mergeStateStatus === "string" && prData.mergeStateStatus.trim().length > 0
|
|
350
403
|
? prData.mergeStateStatus.trim().toUpperCase()
|
|
351
404
|
: null;
|
|
405
|
+
const mergeable = typeof prData?.mergeable === "string" && prData.mergeable.trim().length > 0
|
|
406
|
+
? prData.mergeable.trim().toUpperCase()
|
|
407
|
+
: null;
|
|
352
408
|
const isDraft = Boolean(prData?.isDraft);
|
|
353
409
|
const isClosed = String(prData?.state || "").toUpperCase() === "CLOSED";
|
|
354
410
|
const isMerged = String(prData?.state || "").toUpperCase() === "MERGED";
|
|
@@ -361,6 +417,7 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
|
361
417
|
pr: options.pr,
|
|
362
418
|
currentHeadSha,
|
|
363
419
|
mergeStateStatus,
|
|
420
|
+
mergeable,
|
|
364
421
|
conflictFiles,
|
|
365
422
|
prData,
|
|
366
423
|
snapshot,
|
|
@@ -396,6 +453,7 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
396
453
|
const draftGateConfig = resolveGateConfig(config, "draft");
|
|
397
454
|
const maxCopilotRounds = resolveRefinementConfig(config, "maxCopilotRounds");
|
|
398
455
|
const requireRetrospectiveGate = resolveWorkflowConfig(config, "requireRetrospectiveGate");
|
|
456
|
+
const requireRetrospectiveInternalTooling = resolveWorkflowConfig(config, "requireRetrospectiveInternalTooling");
|
|
399
457
|
const retrospectiveCheckpoint = await loadRetrospectiveCheckpoint(repoRoot);
|
|
400
458
|
const result = evaluatePrGateCoordination({
|
|
401
459
|
repo: context.repo,
|
|
@@ -406,7 +464,11 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
406
464
|
prMerged: String(context.prData?.state || "").toUpperCase() === "MERGED",
|
|
407
465
|
prTitle: context.prData?.title,
|
|
408
466
|
mergeStateStatus: context.mergeStateStatus,
|
|
467
|
+
mergeable: context.mergeable,
|
|
409
468
|
conflictFiles: context.conflictFiles,
|
|
469
|
+
// UI e2e auto-scoping (#976): path-triggered + fail-closed precondition.
|
|
470
|
+
changedFiles: extractChangedFiles(context.prData),
|
|
471
|
+
uiE2ePassed: deriveUiE2ePassed(context.prData),
|
|
410
472
|
lifecycleState: context.interpretation.state,
|
|
411
473
|
loopDisposition: context.disposition.loopDisposition,
|
|
412
474
|
ciStatus: context.snapshot?.ciStatus ?? null,
|
|
@@ -415,6 +477,7 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
415
477
|
sameHeadCleanConverged: context.interpretation.sameHeadCleanConverged,
|
|
416
478
|
draftGateRequireCi: draftGateConfig.requireCi,
|
|
417
479
|
requireRetrospectiveGate,
|
|
480
|
+
requireRetrospectiveInternalTooling,
|
|
418
481
|
retrospectiveCheckpoint,
|
|
419
482
|
draftGate: context.gateEvidence.draftGate,
|
|
420
483
|
draftGateMarker: context.gateEvidence.draftGateMarker,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Bounded docs-grill disposition classifier behind `dev-loop`. Sibling of
|
|
2
|
+
// scripts/loop/slides-story-review-contract.mjs: this one codifies the keep/fix
|
|
3
|
+
// rule for the autonomous docs-grill step (claims vs the actual contracts,
|
|
4
|
+
// code-vs-doc drift, stale references, contract-surface accuracy). Pure module,
|
|
5
|
+
// no I/O. See docs/docs-grill-step.md.
|
|
6
|
+
|
|
7
|
+
// What each finding asserts is wrong. `drift` covers any divergence between a
|
|
8
|
+
// claim/reference and the contract surface it points at; `stale_reference`
|
|
9
|
+
// covers a link/path/command that no longer resolves; `cosmetic` covers
|
|
10
|
+
// wording-only nits with no behavioral or reference impact.
|
|
11
|
+
export const DOCS_GRILL_FINDING_KINDS = Object.freeze([
|
|
12
|
+
'drift',
|
|
13
|
+
'stale_reference',
|
|
14
|
+
'cosmetic',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// The bounded disposition the step assigns to each finding.
|
|
18
|
+
export const DOCS_GRILL_DISPOSITIONS = Object.freeze([
|
|
19
|
+
'record_finding', // real drift between code/behavior and a contract claim — record it
|
|
20
|
+
'fix_in_place', // doc-only drift the loop can correct on this branch
|
|
21
|
+
'route_followup', // doc-only drift too large for this branch — route a follow-up
|
|
22
|
+
'ignore_cosmetic', // wording nit that does not justify a block or a fix here
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Classify one docs-grill finding into its keep/fix disposition.
|
|
27
|
+
*
|
|
28
|
+
* The keep/fix rule (docs/docs-grill-step.md):
|
|
29
|
+
* - real drift between code/behavior and a contract claim -> record_finding
|
|
30
|
+
* - doc-only drift the loop can correct here -> fix_in_place
|
|
31
|
+
* - doc-only drift too large for this branch -> route_followup
|
|
32
|
+
* - cosmetic wording nit -> ignore_cosmetic
|
|
33
|
+
*
|
|
34
|
+
* A coerced/invalid finding returns a structured invalid result rather than
|
|
35
|
+
* throwing (the module's contract is a structured result, never an exception).
|
|
36
|
+
*
|
|
37
|
+
* @param {object} finding
|
|
38
|
+
* @param {string} finding.kind - one of DOCS_GRILL_FINDING_KINDS
|
|
39
|
+
* @param {boolean} [finding.docOnly] - true when only docs (no code/behavior) diverge
|
|
40
|
+
* @param {boolean} [finding.fixableHere] - true when a doc-only fix is small enough for this branch
|
|
41
|
+
* @returns {{ ok: boolean, disposition?: string, status: string, reason: string, invalid?: string[] }}
|
|
42
|
+
*/
|
|
43
|
+
export function classifyDocsGrillFinding(finding = {}) {
|
|
44
|
+
if (!finding || typeof finding !== 'object') finding = {};
|
|
45
|
+
if (!DOCS_GRILL_FINDING_KINDS.includes(finding.kind)) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
status: 'invalid_finding',
|
|
49
|
+
reason: 'unknown_finding_kind',
|
|
50
|
+
invalid: ['kind'],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (finding.kind === 'cosmetic') {
|
|
55
|
+
return { ok: true, disposition: 'ignore_cosmetic', status: 'classified', reason: 'cosmetic_nit' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// drift | stale_reference against live code/behavior is always recorded; the
|
|
59
|
+
// reason reflects the kind so downstream filtering stays accurate.
|
|
60
|
+
if (finding.docOnly !== true) {
|
|
61
|
+
const reason = finding.kind === 'stale_reference' ? 'stale_reference' : 'real_drift';
|
|
62
|
+
return { ok: true, disposition: 'record_finding', status: 'classified', reason };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Doc-only drift: fix it here when small, otherwise route a follow-up.
|
|
66
|
+
if (finding.fixableHere === true) {
|
|
67
|
+
return { ok: true, disposition: 'fix_in_place', status: 'classified', reason: 'doc_only_fixable_here' };
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, disposition: 'route_followup', status: 'classified', reason: 'doc_only_too_large' };
|
|
70
|
+
}
|
package/scripts/loop/info.mjs
CHANGED
|
@@ -100,13 +100,32 @@ function formatCiDisplay(ciStatus, ciConclusion) {
|
|
|
100
100
|
return `CI ${ciStatus}`;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
function formatMergeableDisplay(mergeable, mergeStateStatus) {
|
|
104
|
+
const m = typeof mergeable === "string" ? mergeable.toUpperCase() : null;
|
|
105
|
+
const s = typeof mergeStateStatus === "string" ? mergeStateStatus.toUpperCase() : null;
|
|
106
|
+
if (m === "CONFLICTING" || s === "DIRTY" || s === "CONFLICTING") {
|
|
107
|
+
return `❌ CONFLICTING${s ? ` (${s})` : ""} — resolve before any gate`;
|
|
108
|
+
}
|
|
109
|
+
if (s === "BEHIND") {
|
|
110
|
+
return "⚠️ BEHIND — update branch from base before any gate";
|
|
111
|
+
}
|
|
112
|
+
if (m === "UNKNOWN") {
|
|
113
|
+
return "⏳ UNKNOWN — GitHub still computing; recheck before proceeding";
|
|
114
|
+
}
|
|
115
|
+
if (m === "MERGEABLE") {
|
|
116
|
+
return `✅ MERGEABLE${s ? ` (${s})` : ""}`;
|
|
117
|
+
}
|
|
118
|
+
return s || m || "unknown";
|
|
119
|
+
}
|
|
120
|
+
|
|
103
121
|
function formatPrSummary(prData, handoffResult) {
|
|
104
122
|
const lines = [];
|
|
105
123
|
lines.push(`PR #${prData.number}: ${prData.title}`);
|
|
106
124
|
lines.push(` Branch: ${formatBranchDisplay(prData.headRefName, prData.baseRefName)}`);
|
|
107
125
|
lines.push(` State: ${prData.state}${prData.isDraft ? " (draft)" : ""}`);
|
|
108
126
|
lines.push(` Author: ${prData.author?.login || "unknown"}`);
|
|
109
|
-
|
|
127
|
+
lines.push(` Mergeable: ${formatMergeableDisplay(prData.mergeable, prData.mergeStateStatus)}`);
|
|
128
|
+
|
|
110
129
|
if (handoffResult?.snapshot) {
|
|
111
130
|
const s = handoffResult.snapshot;
|
|
112
131
|
if (s.ciStatus !== undefined) {
|
|
@@ -189,7 +208,7 @@ function formatIssueSummary(issueData, startupBundle, linkedPrData) {
|
|
|
189
208
|
}
|
|
190
209
|
|
|
191
210
|
function buildPrInfo(prNumber, repo, cwd) {
|
|
192
|
-
const prData = ghJson(["pr", "view", String(prNumber), "--repo", repo, "--json", "number,title,body,state,isDraft,headRefName,baseRefName,author,mergedAt,url,reviewRequests"], cwd);
|
|
211
|
+
const prData = ghJson(["pr", "view", String(prNumber), "--repo", repo, "--json", "number,title,body,state,isDraft,headRefName,baseRefName,author,mergedAt,mergeable,mergeStateStatus,url,reviewRequests"], cwd);
|
|
193
212
|
|
|
194
213
|
let handoffResult = null;
|
|
195
214
|
try {
|
|
@@ -5,6 +5,7 @@ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helper
|
|
|
5
5
|
import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
|
|
6
6
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
7
7
|
import { resolveRunId as resolveEnvRunId } from "@dev-loops/core/loop/run-context";
|
|
8
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
8
9
|
import {
|
|
9
10
|
assertRunnerOwnership,
|
|
10
11
|
claimRunnerOwnership,
|
|
@@ -23,6 +24,7 @@ If --run-id is omitted for claim/assert/release/takeover, DEVLOOPS_RUN_ID is use
|
|
|
23
24
|
Output:
|
|
24
25
|
stdout: { "ok": true, ... }
|
|
25
26
|
stderr: { "ok": false, "error": "...", ... }
|
|
27
|
+
${JQ_OUTPUT_USAGE}
|
|
26
28
|
Exit codes:
|
|
27
29
|
0 Success / clean stop-compatible result
|
|
28
30
|
1 Argument error or coordination conflict`.trim();
|
|
@@ -36,6 +38,8 @@ function parseCliArgs(argv) {
|
|
|
36
38
|
pr: undefined,
|
|
37
39
|
runId: undefined,
|
|
38
40
|
requireExisting: false,
|
|
41
|
+
jq: undefined,
|
|
42
|
+
silent: false,
|
|
39
43
|
};
|
|
40
44
|
const command = args.shift();
|
|
41
45
|
if (command === undefined || command === "--help" || command === "-h") {
|
|
@@ -51,6 +55,7 @@ function parseCliArgs(argv) {
|
|
|
51
55
|
pr: { type: "string" },
|
|
52
56
|
"run-id": { type: "string" },
|
|
53
57
|
"require-existing": { type: "boolean" },
|
|
58
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
54
59
|
},
|
|
55
60
|
allowPositionals: true,
|
|
56
61
|
strict: false,
|
|
@@ -83,6 +88,14 @@ function parseCliArgs(argv) {
|
|
|
83
88
|
options.requireExisting = true;
|
|
84
89
|
continue;
|
|
85
90
|
}
|
|
91
|
+
if (token.name === "jq") {
|
|
92
|
+
options.jq = requireTokenValue(token, parseError);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (token.name === "silent") {
|
|
96
|
+
options.silent = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
86
99
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
87
100
|
}
|
|
88
101
|
const validCommands = new Set(["status", "claim", "takeover", "assert", "release"]);
|
|
@@ -146,11 +159,14 @@ async function main() {
|
|
|
146
159
|
}
|
|
147
160
|
const result = await runPrRunnerCoordination(options, { env: process.env });
|
|
148
161
|
if (!result.ok) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
162
|
+
// Preserve the established stderr-on-failure contract when not filtering.
|
|
163
|
+
if (options.jq === undefined && !options.silent) {
|
|
164
|
+
console.error(JSON.stringify(result));
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
152
168
|
}
|
|
153
|
-
|
|
169
|
+
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
|
|
154
170
|
} catch (error) {
|
|
155
171
|
const payload = formatCliError(error, { usage: USAGE });
|
|
156
172
|
console.error(JSON.stringify(payload));
|
|
@@ -23,18 +23,37 @@ import { detectRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
|
23
23
|
import { isCopilotLogin } from "@dev-loops/core/github/copilot-helpers";
|
|
24
24
|
import { loadDevLoopConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
|
|
25
25
|
import { createPiAdapter } from "@dev-loops/core/harness";
|
|
26
|
+
import { validatePlanFile } from "../refine/validate-plan-file.mjs";
|
|
27
|
+
import {
|
|
28
|
+
validateSpikeExplorationSections,
|
|
29
|
+
SPIKE_FILE_EXIT_MARKER_SECTION,
|
|
30
|
+
} from "../refine/validate-spike-file.mjs";
|
|
31
|
+
import { extractSection } from "../refine/_refine-helpers.mjs";
|
|
32
|
+
import {
|
|
33
|
+
evaluatePlanFileIntakeState,
|
|
34
|
+
PLAN_FILE_REFINEMENT_SECTIONS,
|
|
35
|
+
} from "@dev-loops/core/loop/plan-file-intake-contract";
|
|
36
|
+
import { evaluateSpikeIntakeState } from "@dev-loops/core/loop/spike-intake-contract";
|
|
26
37
|
import { parseArgs } from "node:util";
|
|
27
38
|
const USAGE = `Usage:
|
|
28
39
|
resolve-dev-loop-startup.mjs --issue <number>
|
|
29
40
|
resolve-dev-loop-startup.mjs --pr <number>
|
|
30
41
|
resolve-dev-loop-startup.mjs --input <path>
|
|
42
|
+
resolve-dev-loop-startup.mjs --plan-file <path>
|
|
43
|
+
resolve-dev-loop-startup.mjs --spike <path>
|
|
31
44
|
Resolve the authoritative public dev-loop startup/resume bundle.
|
|
32
45
|
Auto-resolves state from GitHub API, git remote, and settings when
|
|
33
46
|
--issue or --pr is used. Use --input for non-standard states.
|
|
47
|
+
Use --plan-file to start local planning from a phase-doc-format plan
|
|
48
|
+
(read-only: no tracker mutation, no issue/PR number).
|
|
49
|
+
Use --spike to start a time-boxed exploratory loop from a local spike
|
|
50
|
+
artifact (read-only: no tracker mutation, no issue/PR number).
|
|
34
51
|
Required (exactly one):
|
|
35
52
|
--issue <n> Target an issue by number (auto-resolves all state)
|
|
36
53
|
--pr <n> Target a PR by number (auto-resolves all state)
|
|
37
54
|
--input <path> Path to a JSON file with canonical-state payload
|
|
55
|
+
--plan-file <path> Path to a phase-doc-format plan to start locally
|
|
56
|
+
--spike <path> Path to a spike artifact to start a spike loop locally
|
|
38
57
|
Exit codes:
|
|
39
58
|
0 Success
|
|
40
59
|
1 Argument error, runtime failure, or async-start contract rejection`.trim();
|
|
@@ -102,6 +121,8 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
|
|
|
102
121
|
inputPath: undefined,
|
|
103
122
|
issue: undefined,
|
|
104
123
|
pr: undefined,
|
|
124
|
+
planFile: undefined,
|
|
125
|
+
spike: undefined,
|
|
105
126
|
};
|
|
106
127
|
const { tokens } = parseArgs({
|
|
107
128
|
args: [...argv],
|
|
@@ -110,6 +131,8 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
|
|
|
110
131
|
input: { type: "string" },
|
|
111
132
|
issue: { type: "string" },
|
|
112
133
|
pr: { type: "string" },
|
|
134
|
+
"plan-file": { type: "string" },
|
|
135
|
+
spike: { type: "string" },
|
|
113
136
|
},
|
|
114
137
|
allowPositionals: true,
|
|
115
138
|
strict: false,
|
|
@@ -138,14 +161,22 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
|
|
|
138
161
|
options.pr = parsePositiveInteger(requireTokenValue(token, parseError), "--pr", parseError);
|
|
139
162
|
continue;
|
|
140
163
|
}
|
|
164
|
+
if (token.name === "plan-file") {
|
|
165
|
+
options.planFile = requireTokenValue(token, parseError);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (token.name === "spike") {
|
|
169
|
+
options.spike = requireTokenValue(token, parseError);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
141
172
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
142
173
|
}
|
|
143
|
-
const modeCount = [options.inputPath, options.issue, options.pr].filter(v => v !== undefined).length;
|
|
174
|
+
const modeCount = [options.inputPath, options.issue, options.pr, options.planFile, options.spike].filter(v => v !== undefined).length;
|
|
144
175
|
if (modeCount > 1) {
|
|
145
|
-
throw parseError("--issue, --pr, and --
|
|
176
|
+
throw parseError("--issue, --pr, --input, --plan-file, and --spike are mutually exclusive; provide exactly one");
|
|
146
177
|
}
|
|
147
178
|
if (modeCount === 0) {
|
|
148
|
-
throw parseError("--input <path>, --issue <n>, or --
|
|
179
|
+
throw parseError("--input <path>, --issue <n>, --pr <n>, --plan-file <path>, or --spike <path> is required");
|
|
149
180
|
}
|
|
150
181
|
return options;
|
|
151
182
|
}
|
|
@@ -368,6 +399,118 @@ export function buildAutoResolvedInput({ issue, pr, cwd, targetPreference, input
|
|
|
368
399
|
},
|
|
369
400
|
};
|
|
370
401
|
}
|
|
402
|
+
/**
|
|
403
|
+
* Read + validate a `--plan-file` path and build a local_phase startup input.
|
|
404
|
+
*
|
|
405
|
+
* Read-only: no tracker mutation, no GitHub calls, no issue/PR number. A
|
|
406
|
+
* missing/unreadable file, or one failing the base-section validator, throws so
|
|
407
|
+
* the CLI fails closed (exit 1, no readiness bundle). The plan-file path is
|
|
408
|
+
* carried as the target `phase` and is exempt from the worktree-isolation guard
|
|
409
|
+
* because there is no issue to key a worktree on before promotion.
|
|
410
|
+
*
|
|
411
|
+
* @returns {object} startup input with a `planFileIntakeState` field threaded onto output
|
|
412
|
+
*/
|
|
413
|
+
export function buildPlanFileInput({ planFilePath }) {
|
|
414
|
+
const resolvedPath = path.resolve(planFilePath);
|
|
415
|
+
let markdownText;
|
|
416
|
+
try {
|
|
417
|
+
markdownText = readFileSync(resolvedPath, "utf8");
|
|
418
|
+
} catch (err) {
|
|
419
|
+
throw new Error(`Plan file is missing or unreadable: ${resolvedPath} (${err instanceof Error ? err.message : String(err)})`);
|
|
420
|
+
}
|
|
421
|
+
const validation = validatePlanFile(markdownText);
|
|
422
|
+
if (!validation.ok) {
|
|
423
|
+
const codes = validation.errors.map(e => e.code).join(", ");
|
|
424
|
+
throw new Error(`Plan file failed validation (${resolvedPath}): ${codes}`);
|
|
425
|
+
}
|
|
426
|
+
// Refined-vs-needs-refinement: refinement adds Acceptance criteria + Definition
|
|
427
|
+
// of done on top of the base authoring sections. Detect each via extractSection
|
|
428
|
+
// (non-null trimmed body == present and non-empty), then classify with the pure
|
|
429
|
+
// intake evaluator.
|
|
430
|
+
const [acHeading, dodHeading] = PLAN_FILE_REFINEMENT_SECTIONS;
|
|
431
|
+
const hasAcceptanceCriteria = extractSection(markdownText, acHeading) ? true : false;
|
|
432
|
+
const hasDefinitionOfDone = extractSection(markdownText, dodHeading) ? true : false;
|
|
433
|
+
const { state: planFileIntakeState } = evaluatePlanFileIntakeState({
|
|
434
|
+
baseSectionsValid: true,
|
|
435
|
+
hasAcceptanceCriteria,
|
|
436
|
+
hasDefinitionOfDone,
|
|
437
|
+
});
|
|
438
|
+
return {
|
|
439
|
+
intent: "start_issue_locally",
|
|
440
|
+
mode: "bounded_handoff",
|
|
441
|
+
targetPreference: "prefer_local",
|
|
442
|
+
artifactState: "not_applicable",
|
|
443
|
+
issueLinkageResolution: "not_applicable",
|
|
444
|
+
issueReadiness: "not_applicable",
|
|
445
|
+
issueAssignmentState: "not_applicable",
|
|
446
|
+
loopState: "implementation_pending",
|
|
447
|
+
planFileIntakeState,
|
|
448
|
+
planFileExempt: true,
|
|
449
|
+
currentState: {
|
|
450
|
+
target: { kind: "local_phase", issue: null, pr: null, linkedPr: null, branch: null, phase: resolvedPath },
|
|
451
|
+
ownership: "local",
|
|
452
|
+
nextActor: "local",
|
|
453
|
+
status: "active",
|
|
454
|
+
authorization: "authorized",
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Read + validate a `--spike` path and build a local_phase startup input for a
|
|
460
|
+
* time-boxed exploratory loop.
|
|
461
|
+
*
|
|
462
|
+
* Read-only: no tracker mutation, no GitHub calls, no issue/PR number — a spike
|
|
463
|
+
* is startable from a local question with no GitHub issue and no production-gate
|
|
464
|
+
* ceremony at entry. A missing/unreadable file, or one missing the exploration
|
|
465
|
+
* scaffold (Question/Approach/Findings), throws so the CLI fails closed (exit 1,
|
|
466
|
+
* no readiness bundle). The Recommendation section is filled in during the spike;
|
|
467
|
+
* its presence flips the intake state to spike_ready_for_exit (the seam phase 2's
|
|
468
|
+
* discard/graduate exits consume). The spike path is carried as the target
|
|
469
|
+
* `phase` and is exempt from the worktree-isolation guard because there is no
|
|
470
|
+
* issue to key a worktree on.
|
|
471
|
+
*
|
|
472
|
+
* @returns {object} startup input with a `spikeIntakeState` field threaded onto output
|
|
473
|
+
*/
|
|
474
|
+
export function buildSpikeInput({ spikeFilePath }) {
|
|
475
|
+
const resolvedPath = path.resolve(spikeFilePath);
|
|
476
|
+
let markdownText;
|
|
477
|
+
try {
|
|
478
|
+
markdownText = readFileSync(resolvedPath, "utf8");
|
|
479
|
+
} catch (err) {
|
|
480
|
+
throw new Error(`Spike file is missing or unreadable: ${resolvedPath} (${err instanceof Error ? err.message : String(err)})`);
|
|
481
|
+
}
|
|
482
|
+
// Entry gate: the exploration scaffold must be present and non-empty. A
|
|
483
|
+
// malformed spike artifact fails closed before any intake classification.
|
|
484
|
+
const validation = validateSpikeExplorationSections(markdownText);
|
|
485
|
+
if (!validation.ok) {
|
|
486
|
+
const codes = validation.errors.map(e => e.code).join(", ");
|
|
487
|
+
throw new Error(`Spike file failed validation (${resolvedPath}): ${codes}`);
|
|
488
|
+
}
|
|
489
|
+
const hasRecommendation = extractSection(markdownText, SPIKE_FILE_EXIT_MARKER_SECTION) ? true : false;
|
|
490
|
+
const { state: spikeIntakeState } = evaluateSpikeIntakeState({
|
|
491
|
+
baseSectionsValid: true,
|
|
492
|
+
hasRecommendation,
|
|
493
|
+
});
|
|
494
|
+
return {
|
|
495
|
+
intent: "start_issue_locally",
|
|
496
|
+
mode: "bounded_handoff",
|
|
497
|
+
targetPreference: "prefer_local",
|
|
498
|
+
artifactState: "not_applicable",
|
|
499
|
+
issueLinkageResolution: "not_applicable",
|
|
500
|
+
issueReadiness: "not_applicable",
|
|
501
|
+
issueAssignmentState: "not_applicable",
|
|
502
|
+
loopState: "implementation_pending",
|
|
503
|
+
spikeIntakeState,
|
|
504
|
+
planFileExempt: true,
|
|
505
|
+
currentState: {
|
|
506
|
+
target: { kind: "local_phase", issue: null, pr: null, linkedPr: null, branch: null, phase: resolvedPath },
|
|
507
|
+
ownership: "local",
|
|
508
|
+
nextActor: "local",
|
|
509
|
+
status: "active",
|
|
510
|
+
authorization: "authorized",
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
}
|
|
371
514
|
export function summarizeCanonicalState(bundle) {
|
|
372
515
|
return {
|
|
373
516
|
target: bundle.canonicalState?.target ?? null,
|
|
@@ -390,6 +533,16 @@ export function summarizeCanonicalState(bundle) {
|
|
|
390
533
|
export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdapter(), env, cwd, asyncStartMode = "required" } = {}) {
|
|
391
534
|
const effectiveEnv = env ?? adapter.getEnv();
|
|
392
535
|
const effectiveCwd = cwd ?? adapter.getCwd();
|
|
536
|
+
// Normalize a non-object input (e.g. `--input null`, which parses to a legal
|
|
537
|
+
// JSON null) to {} so the destructure below cannot throw before routing can
|
|
538
|
+
// fail closed with a structured reconcile bundle.
|
|
539
|
+
if (input === null || typeof input !== "object") input = {};
|
|
540
|
+
// Plan-file intake carries two resolver-only fields that the pure routing
|
|
541
|
+
// evaluator does not model. Strip them before evaluation and re-apply them to
|
|
542
|
+
// the result; `planFileExempt` waives the worktree-isolation guard because a
|
|
543
|
+
// pre-promotion plan has no issue to key a worktree on.
|
|
544
|
+
const { planFileExempt = false, planFileIntakeState = null, spikeIntakeState = null, ...routingInput } = input;
|
|
545
|
+
input = routingInput;
|
|
393
546
|
try {
|
|
394
547
|
const checkpointText = readFileSync(
|
|
395
548
|
path.join(effectiveCwd, ".pi", "dev-loop-retrospective-checkpoint.json"),
|
|
@@ -440,6 +593,7 @@ export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdap
|
|
|
440
593
|
const DEVLOOPS_WORKTREE_BYPASS_VAR = "DEVLOOPS_WORKTREE_BYPASS";
|
|
441
594
|
if (
|
|
442
595
|
strategyKey === "local_implementation" &&
|
|
596
|
+
!planFileExempt &&
|
|
443
597
|
(effectiveEnv[DEVLOOPS_WORKTREE_BYPASS_VAR] ?? "").trim() !== "1"
|
|
444
598
|
) {
|
|
445
599
|
try {
|
|
@@ -496,6 +650,8 @@ export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdap
|
|
|
496
650
|
requiredReads: STRATEGY_REQUIRED_READS[strategyKey],
|
|
497
651
|
nextAction: bundle.nextAction,
|
|
498
652
|
canonicalStateSummary: summarizeCanonicalState(bundle),
|
|
653
|
+
...(planFileIntakeState !== null ? { planFileIntakeState } : {}),
|
|
654
|
+
...(spikeIntakeState !== null ? { spikeIntakeState } : {}),
|
|
499
655
|
bundle,
|
|
500
656
|
};
|
|
501
657
|
}
|
|
@@ -521,9 +677,24 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
|
|
|
521
677
|
? normalizeConfigInputSource(devLoopConfig?.inputSource?.default)
|
|
522
678
|
: "tracker";
|
|
523
679
|
let input;
|
|
524
|
-
if (options.
|
|
680
|
+
if (options.spike !== undefined) {
|
|
681
|
+
input = buildSpikeInput({ spikeFilePath: options.spike });
|
|
682
|
+
} else if (options.planFile !== undefined) {
|
|
683
|
+
input = buildPlanFileInput({ planFilePath: options.planFile });
|
|
684
|
+
} else if (options.inputPath !== undefined) {
|
|
525
685
|
const text = await readFile(path.resolve(options.inputPath), "utf8");
|
|
526
|
-
|
|
686
|
+
const parsed = parseJsonText(text);
|
|
687
|
+
// `--input` is untrusted external JSON. Strip the resolver-only intake fields
|
|
688
|
+
// so it cannot inject them — `planFileExempt` would otherwise waive the
|
|
689
|
+
// worktree-isolation guard for a normal local_implementation, and the intake
|
|
690
|
+
// state is owned by the internal plan-file path (buildPlanFileInput), not the
|
|
691
|
+
// caller.
|
|
692
|
+
if (parsed && typeof parsed === "object") {
|
|
693
|
+
delete parsed.planFileExempt;
|
|
694
|
+
delete parsed.planFileIntakeState;
|
|
695
|
+
delete parsed.spikeIntakeState;
|
|
696
|
+
}
|
|
697
|
+
input = parsed;
|
|
527
698
|
} else if (options.issue !== undefined) {
|
|
528
699
|
input = buildAutoResolvedInput({
|
|
529
700
|
issue: options.issue,
|