dev-loops 0.2.7 → 0.4.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/developer.md +1 -0
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/review.md +30 -0
- package/.claude/hooks/_run-context.mjs +9 -16
- package/.claude/skills/copilot-pr-followup/SKILL.md +62 -6
- package/.claude/skills/dev-loop/SKILL.md +6 -6
- package/.claude/skills/docs/anti-patterns.md +2 -0
- package/.claude/skills/docs/copilot-loop-operations.md +3 -3
- package/.claude/skills/docs/issue-intake-procedure.md +4 -0
- package/.claude/skills/docs/merge-preconditions.md +65 -1
- package/.claude/skills/docs/stop-conditions.md +1 -0
- package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
- package/.claude/skills/local-implementation/SKILL.md +25 -5
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +69 -0
- package/README.md +8 -2
- package/agents/developer.agent.md +1 -0
- package/agents/fixer.agent.md +1 -0
- package/agents/review.agent.md +30 -0
- package/cli/index.mjs +60 -8
- package/extension/README.md +1 -1
- package/package.json +3 -3
- package/scripts/README.md +8 -7
- package/scripts/_core-helpers.mjs +1 -0
- package/scripts/claude/headless-dev-loop.mjs +53 -13
- package/scripts/claude/headless-info-smoke.mjs +45 -11
- package/scripts/github/build-adjacent-bundle.mjs +448 -0
- package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
- package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
- package/scripts/github/offer-human-handoff.mjs +147 -0
- package/scripts/github/post-gate-findings.mjs +392 -0
- package/scripts/github/probe-ci-status.mjs +468 -0
- package/scripts/github/reconcile-draft-gate.mjs +2 -2
- package/scripts/github/request-copilot-review.mjs +72 -11
- package/scripts/github/resolve-handoff-candidates.mjs +412 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +599 -17
- package/scripts/github/verify-fresh-review-context.mjs +12 -1
- package/scripts/github/write-gate-context.mjs +634 -0
- package/scripts/github/write-gate-findings-log.mjs +1 -1
- package/scripts/loop/_stale-runner-detection.mjs +1 -1
- package/scripts/loop/_worktree-path.mjs +27 -0
- package/scripts/loop/cleanup-worktree.mjs +175 -0
- package/scripts/loop/copilot-pr-handoff.mjs +1 -1
- package/scripts/loop/detect-change-scope.mjs +36 -11
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
- package/scripts/loop/detect-stale-runner.mjs +3 -4
- package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
- package/scripts/loop/ensure-worktree.mjs +219 -0
- package/scripts/loop/outer-loop.mjs +1 -1
- package/scripts/loop/pr-runner-coordination.mjs +1 -1
- package/scripts/loop/pre-flight-gate.mjs +10 -7
- package/scripts/loop/pre-push-main-guard.mjs +4 -4
- package/scripts/loop/provision-worktree.mjs +243 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
- package/scripts/loop/run-queue.mjs +112 -16
- package/scripts/loop/run-watch-cycle.mjs +75 -22
- package/scripts/projects/add-queue-item.mjs +80 -48
- package/scripts/projects/archive-done-items.mjs +136 -39
- package/scripts/projects/ensure-queue-board.mjs +67 -65
- package/scripts/projects/list-queue-items.mjs +59 -57
- package/scripts/projects/move-queue-item.mjs +125 -125
- package/scripts/projects/reorder-queue-item.mjs +67 -48
- package/scripts/projects/sync-item-status.mjs +199 -0
- package/skills/copilot-pr-followup/SKILL.md +62 -6
- package/skills/dev-loop/SKILL.md +2 -2
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
- package/skills/dev-loop/scripts/phase-files.mjs +2 -2
- package/skills/docs/anti-patterns.md +2 -0
- package/skills/docs/copilot-loop-operations.md +3 -3
- package/skills/docs/issue-intake-procedure.md +4 -0
- package/skills/docs/merge-preconditions.md +65 -1
- package/skills/docs/stop-conditions.md +1 -0
- package/skills/docs/tracker-first-loop-state.md +6 -6
- package/skills/local-implementation/SKILL.md +25 -5
|
@@ -14,10 +14,13 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { parseArgs } from "node:util";
|
|
17
18
|
import { runQueue, DEFAULT_QUEUE_DRIVER_OPTIONS } from "@dev-loops/core/loop/queue-driver";
|
|
18
19
|
import { computeParallelSchedule } from "@dev-loops/core/loop/queue-parallel";
|
|
19
20
|
import { readQueue } from "@dev-loops/core/loop/queue-state";
|
|
21
|
+
import { reconcileBoardMembership } from "@dev-loops/core/loop/queue-membership";
|
|
20
22
|
import { parsePositiveInteger } from "@dev-loops/core/cli/primitives";
|
|
23
|
+
import { loadDevLoopConfig, resolveEffectiveMergeAuthorizedFromLoad } from "@dev-loops/core/config";
|
|
21
24
|
|
|
22
25
|
const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
|
|
23
26
|
|
|
@@ -27,7 +30,7 @@ const USAGE = `Usage:
|
|
|
27
30
|
Run the dev-loop queue driver over entries in .pi/dev-loop-queue.json.
|
|
28
31
|
Exit codes: 0 success, 1 error`.trim();
|
|
29
32
|
|
|
30
|
-
function
|
|
33
|
+
function parseCliArgs(argv) {
|
|
31
34
|
const args = {
|
|
32
35
|
repo: null,
|
|
33
36
|
mergeAuthorized: false,
|
|
@@ -37,27 +40,58 @@ function parseArgs(argv) {
|
|
|
37
40
|
help: false,
|
|
38
41
|
};
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
const { tokens } = parseArgs({
|
|
44
|
+
args: [...argv],
|
|
45
|
+
options: {
|
|
46
|
+
repo: { type: "string" },
|
|
47
|
+
"merge-authorized": { type: "boolean" },
|
|
48
|
+
parallel: { type: "boolean" },
|
|
49
|
+
"redispatch-max-retries": { type: "string" },
|
|
50
|
+
"max-parallel": { type: "string" },
|
|
51
|
+
help: { type: "boolean", short: "h" },
|
|
52
|
+
},
|
|
53
|
+
allowPositionals: true,
|
|
54
|
+
strict: false,
|
|
55
|
+
tokens: true,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
for (const token of tokens) {
|
|
59
|
+
if (token.kind === "positional") {
|
|
60
|
+
throw new Error(`unknown argument: ${token.value}`);
|
|
61
|
+
}
|
|
62
|
+
if (token.kind !== "option") {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
switch (token.name) {
|
|
66
|
+
case "repo":
|
|
67
|
+
args.repo = token.value;
|
|
44
68
|
break;
|
|
45
|
-
case "
|
|
69
|
+
case "merge-authorized":
|
|
70
|
+
if (token.value !== undefined) {
|
|
71
|
+
throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
|
|
72
|
+
}
|
|
46
73
|
args.mergeAuthorized = true;
|
|
47
74
|
break;
|
|
48
|
-
case "
|
|
75
|
+
case "parallel":
|
|
76
|
+
if (token.value !== undefined) {
|
|
77
|
+
throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
|
|
78
|
+
}
|
|
49
79
|
args.parallel = true;
|
|
50
80
|
break;
|
|
51
|
-
case "
|
|
52
|
-
args.reDispatchMaxRetries = parsePositiveInteger(
|
|
81
|
+
case "redispatch-max-retries":
|
|
82
|
+
args.reDispatchMaxRetries = parsePositiveInteger(token.value, "--redispatch-max-retries");
|
|
53
83
|
break;
|
|
54
|
-
case "
|
|
55
|
-
args.maxParallel = parsePositiveInteger(
|
|
84
|
+
case "max-parallel":
|
|
85
|
+
args.maxParallel = parsePositiveInteger(token.value, "--max-parallel");
|
|
56
86
|
break;
|
|
57
|
-
case "
|
|
58
|
-
|
|
87
|
+
case "help":
|
|
88
|
+
if (token.value !== undefined) {
|
|
89
|
+
throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
|
|
90
|
+
}
|
|
59
91
|
args.help = true;
|
|
60
92
|
break;
|
|
93
|
+
default:
|
|
94
|
+
throw new Error(`unknown argument: ${token.rawName}`);
|
|
61
95
|
}
|
|
62
96
|
}
|
|
63
97
|
|
|
@@ -65,7 +99,7 @@ function parseArgs(argv) {
|
|
|
65
99
|
}
|
|
66
100
|
|
|
67
101
|
async function main() {
|
|
68
|
-
const args =
|
|
102
|
+
const args = parseCliArgs(process.argv.slice(2));
|
|
69
103
|
|
|
70
104
|
if (args.help) {
|
|
71
105
|
console.log(USAGE);
|
|
@@ -79,7 +113,46 @@ async function main() {
|
|
|
79
113
|
|
|
80
114
|
const queue = await readQueue(REPO_ROOT);
|
|
81
115
|
|
|
82
|
-
|
|
116
|
+
// A configured GitHub Projects board is the authoritative queue MEMBERSHIP
|
|
117
|
+
// source (issue #864): fold its "Next Up" items into the queue before judging
|
|
118
|
+
// emptiness so a populated board with an empty local queue is no longer a
|
|
119
|
+
// silent no-op. Fail-open — a board hiccup falls back to the local queue.
|
|
120
|
+
// reconcileBoardMembership already logs an "added N ... from board Next Up"
|
|
121
|
+
// line to stderr (single source of truth); we deliberately do not duplicate
|
|
122
|
+
// it here to avoid noise for JSON consumers of stdout.
|
|
123
|
+
const membership = await reconcileBoardMembership(REPO_ROOT, args.repo, queue);
|
|
124
|
+
|
|
125
|
+
if (membership.emptiness === "board_empty") {
|
|
126
|
+
// Distinct from the legacy generic "Queue is empty": the board is the
|
|
127
|
+
// membership source and it currently has nothing in Next Up. This branch is
|
|
128
|
+
// only reached for a genuinely empty Next Up (reason == null), never for a
|
|
129
|
+
// resolution failure (which falls through to the local queue below).
|
|
130
|
+
console.log(JSON.stringify({
|
|
131
|
+
ok: true,
|
|
132
|
+
message: "Board configured but Next Up is empty; nothing to run",
|
|
133
|
+
boardConfigured: true,
|
|
134
|
+
reason: null,
|
|
135
|
+
results: [],
|
|
136
|
+
}));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (membership.emptiness === "board_unavailable") {
|
|
141
|
+
// The board IS configured but Next Up resolution failed (fail-open) and the
|
|
142
|
+
// local queue had nothing to fall back to. Do NOT claim "Next Up is empty";
|
|
143
|
+
// surface the real reason so consumers can distinguish an outage from an
|
|
144
|
+
// intentionally empty board.
|
|
145
|
+
console.log(JSON.stringify({
|
|
146
|
+
ok: true,
|
|
147
|
+
message: `Board configured but unavailable (${membership.reason}); nothing to run`,
|
|
148
|
+
boardConfigured: true,
|
|
149
|
+
reason: membership.reason ?? null,
|
|
150
|
+
results: [],
|
|
151
|
+
}));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (membership.emptiness === "queue_empty") {
|
|
83
156
|
console.log(JSON.stringify({ ok: true, message: "Queue is empty", results: [] }));
|
|
84
157
|
return;
|
|
85
158
|
}
|
|
@@ -109,9 +182,32 @@ async function main() {
|
|
|
109
182
|
console.error("Parallel dispatch via async subagents not yet wired; falling back to sequential.");
|
|
110
183
|
}
|
|
111
184
|
|
|
185
|
+
// Authoritative merge-authorization gate: when the repo enforces
|
|
186
|
+
// humanMergeOnly, --merge-authorized is ignored (fails closed) so the queue
|
|
187
|
+
// driver never auto-merges. `loadDevLoopConfig` never throws — it returns an
|
|
188
|
+
// `errors` array — so a try/catch would not catch an unreadable/invalid
|
|
189
|
+
// `.devloops` (which may be the very file declaring humanMergeOnly). FAIL
|
|
190
|
+
// CLOSED on any config load/validation error: if the config cannot be
|
|
191
|
+
// confirmed, do NOT grant merge authorization. A compliance invariant must
|
|
192
|
+
// never be silently dropped because its config could not be read.
|
|
193
|
+
let effectiveMergeAuthorized = args.mergeAuthorized;
|
|
194
|
+
if (args.mergeAuthorized) {
|
|
195
|
+
const load = await loadDevLoopConfig({ repoRoot: REPO_ROOT });
|
|
196
|
+
effectiveMergeAuthorized = resolveEffectiveMergeAuthorizedFromLoad(args.mergeAuthorized, load);
|
|
197
|
+
if ((load.errors?.length ?? 0) > 0) {
|
|
198
|
+
console.error(
|
|
199
|
+
JSON.stringify({
|
|
200
|
+
ok: true,
|
|
201
|
+
warning: "dev-loop config could not be loaded/validated; failing closed on merge authorization (not auto-merging).",
|
|
202
|
+
errors: load.errors.map((e) => (e && e.message) || String(e)),
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
112
208
|
const result = await runQueue(REPO_ROOT, args.repo, {
|
|
113
209
|
...DEFAULT_QUEUE_DRIVER_OPTIONS,
|
|
114
|
-
mergeAuthorized:
|
|
210
|
+
mergeAuthorized: effectiveMergeAuthorized,
|
|
115
211
|
reDispatchMaxRetries: args.reDispatchMaxRetries,
|
|
116
212
|
});
|
|
117
213
|
|
|
@@ -5,7 +5,10 @@ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helper
|
|
|
5
5
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
6
|
import { DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION } from "@dev-loops/core/loop/public-dev-loop-routing";
|
|
7
7
|
import { watchCopilotReview } from "../github/probe-copilot-review.mjs";
|
|
8
|
+
import { watchCiStatus } from "../github/probe-ci-status.mjs";
|
|
8
9
|
import { runHandoff } from "./copilot-pr-handoff.mjs";
|
|
10
|
+
import { STATE } from "@dev-loops/core/loop/copilot-loop-state";
|
|
11
|
+
import { DEFAULT_POLL_INTERVAL_MS } from "@dev-loops/core/loop/policy-constants";
|
|
9
12
|
import { detectCopilotSessionActivity } from "./detect-copilot-session-activity.mjs";
|
|
10
13
|
import { parseArgs } from "node:util";
|
|
11
14
|
import {
|
|
@@ -124,16 +127,33 @@ function buildWatchCycleContractTrace({
|
|
|
124
127
|
cycleDisposition,
|
|
125
128
|
sessionActivity = null,
|
|
126
129
|
workflowRunWatch = null,
|
|
130
|
+
ciWatchArgs = null,
|
|
131
|
+
ciWatchStatus = null,
|
|
127
132
|
}) {
|
|
128
|
-
|
|
129
|
-
|
|
133
|
+
// The CI-watch branch (waiting_for_ci) routes through probe-ci-status.mjs even
|
|
134
|
+
// though handoff.action !== "watch". It is an observational wait, so it mirrors
|
|
135
|
+
// the Copilot watch classification: a quiet timeout is a HEALTHY_WAIT, while
|
|
136
|
+
// success/failure/changed route a follow-up. Without this the boundary-action
|
|
137
|
+
// path below would misclassify a CI timeout as routed_followup.
|
|
138
|
+
const isCiWatch = ciWatchArgs !== null;
|
|
139
|
+
const isWatchBoundary = handoff.action === "watch" || isCiWatch;
|
|
140
|
+
const observedStatus = isCiWatch ? ciWatchStatus : watchStatus;
|
|
141
|
+
const boundaryClassification = isWatchBoundary
|
|
142
|
+
? (observedStatus === "timeout" || observedStatus === "idle"
|
|
143
|
+
? DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.HEALTHY_WAIT
|
|
144
|
+
: DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.ROUTED_FOLLOWUP)
|
|
145
|
+
: handoff.loopDisposition === "blocked"
|
|
130
146
|
? DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.BLOCKED
|
|
131
147
|
: handoff.terminal
|
|
132
148
|
? DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.TERMINAL
|
|
133
|
-
: DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.ROUTED_FOLLOWUP
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
149
|
+
: DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.ROUTED_FOLLOWUP;
|
|
150
|
+
const healthyWait = boundaryClassification === DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.HEALTHY_WAIT;
|
|
151
|
+
const helper = handoff.action === "watch"
|
|
152
|
+
? "scripts/github/probe-copilot-review.mjs"
|
|
153
|
+
: isCiWatch
|
|
154
|
+
? "scripts/github/probe-ci-status.mjs"
|
|
155
|
+
: null;
|
|
156
|
+
const effectiveArgs = isCiWatch ? ciWatchArgs : watchArgs;
|
|
137
157
|
return {
|
|
138
158
|
handoff: {
|
|
139
159
|
action: handoff.action,
|
|
@@ -142,38 +162,37 @@ function buildWatchCycleContractTrace({
|
|
|
142
162
|
terminal: Boolean(handoff.terminal),
|
|
143
163
|
},
|
|
144
164
|
waitStrategy: {
|
|
145
|
-
helper
|
|
146
|
-
mode:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
effectiveTimeoutMs: watchArgs?.timeoutMs ?? null,
|
|
150
|
-
effectivePollIntervalMs: watchArgs?.pollIntervalMs ?? null,
|
|
165
|
+
helper,
|
|
166
|
+
mode: isWatchBoundary ? "persistent_watch" : "not_applicable",
|
|
167
|
+
effectiveTimeoutMs: effectiveArgs?.timeoutMs ?? null,
|
|
168
|
+
effectivePollIntervalMs: effectiveArgs?.pollIntervalMs ?? null,
|
|
151
169
|
timeoutPolicyClassification: watchTimeoutPolicy?.classification ?? null,
|
|
152
170
|
},
|
|
153
171
|
orchestration: {
|
|
154
172
|
emittedWatchArgs: handoff.watchArgs ?? null,
|
|
155
|
-
effectiveWatchArgs:
|
|
173
|
+
effectiveWatchArgs: effectiveArgs,
|
|
174
|
+
ciWatchArgs,
|
|
156
175
|
sessionActivity,
|
|
157
176
|
workflowRunWatch,
|
|
158
177
|
},
|
|
159
|
-
stateRefresh:
|
|
178
|
+
stateRefresh: isWatchBoundary
|
|
160
179
|
? {
|
|
161
180
|
boundaryKind: "post_watch_or_probe",
|
|
162
|
-
observedStatus
|
|
181
|
+
observedStatus,
|
|
163
182
|
refreshRequired: true,
|
|
164
|
-
refreshReason:
|
|
165
|
-
? "
|
|
166
|
-
: "
|
|
183
|
+
refreshReason: healthyWait
|
|
184
|
+
? "Healthy watch boundaries are observational only; refresh authoritative state before treating timeout/idle as stop or completion."
|
|
185
|
+
: "Watch boundaries with fresh activity require an authoritative state refresh before routing the follow-up path.",
|
|
167
186
|
}
|
|
168
187
|
: null,
|
|
169
188
|
stopReason: {
|
|
170
189
|
classification: boundaryClassification,
|
|
171
190
|
terminal: Boolean(handoff.terminal),
|
|
172
191
|
cycleDisposition,
|
|
173
|
-
reason:
|
|
174
|
-
? (
|
|
175
|
-
? "
|
|
176
|
-
: "
|
|
192
|
+
reason: isWatchBoundary
|
|
193
|
+
? (healthyWait
|
|
194
|
+
? "Quiet watcher boundaries remain healthy waits and must not be treated as terminal completion by themselves."
|
|
195
|
+
: "Fresh watcher activity requires follow-up instead of staying in a healthy wait boundary.")
|
|
177
196
|
: handoff.nextAction,
|
|
178
197
|
},
|
|
179
198
|
};
|
|
@@ -236,6 +255,7 @@ export async function runWatchCycle(
|
|
|
236
255
|
ghCommand = "gh",
|
|
237
256
|
runHandoffImpl = runHandoff,
|
|
238
257
|
watchCopilotReviewImpl = watchCopilotReview,
|
|
258
|
+
watchCiStatusImpl = watchCiStatus,
|
|
239
259
|
detectCopilotSessionActivityImpl = detectCopilotSessionActivity,
|
|
240
260
|
fetchPrHeadBranchImpl = fetchPrHeadBranch,
|
|
241
261
|
watchWorkflowRunImpl = watchWorkflowRun,
|
|
@@ -267,6 +287,39 @@ export async function runWatchCycle(
|
|
|
267
287
|
if (handoff.watchTimeoutPolicy !== undefined) {
|
|
268
288
|
result.watchTimeoutPolicy = handoff.watchTimeoutPolicy;
|
|
269
289
|
}
|
|
290
|
+
// Provider-agnostic CI wait (#917): a waiting_for_ci boundary would otherwise
|
|
291
|
+
// dead-end at action:"stop". Route it to the helper-owned CI watcher
|
|
292
|
+
// (CircleCI / GH Actions / external commit-status), not gh run watch.
|
|
293
|
+
if (handoff.action !== "watch" && handoff.state === STATE.WAITING_FOR_CI) {
|
|
294
|
+
// Mirror determineWatchTimeout but with a CI-specific context label so
|
|
295
|
+
// timeout diagnostics read "CI wait" instead of "Copilot review wait".
|
|
296
|
+
const ciTimeoutMs = enforceExternalHealthyWaitTimeout({
|
|
297
|
+
timeoutMs: EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY.defaultTimeoutMs,
|
|
298
|
+
contextLabel: "CI wait",
|
|
299
|
+
});
|
|
300
|
+
const ciWatchArgs = {
|
|
301
|
+
repo: options.repo,
|
|
302
|
+
pr: options.pr,
|
|
303
|
+
pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
|
|
304
|
+
timeoutMs: ciTimeoutMs,
|
|
305
|
+
};
|
|
306
|
+
const ciWatch = await watchCiStatusImpl(ciWatchArgs, { env, ghCommand });
|
|
307
|
+
result.ciWatchArgs = ciWatchArgs;
|
|
308
|
+
result.ciWatch = ciWatch;
|
|
309
|
+
result.watchStatus = ciWatch.status;
|
|
310
|
+
// success/failure/changed all need authoritative re-detection (follow-up);
|
|
311
|
+
// a quiet timeout stays a healthy pending wait.
|
|
312
|
+
result.cycleDisposition = ciWatch.status === "timeout" ? "pending" : "needs_followup";
|
|
313
|
+
result.terminal = false;
|
|
314
|
+
result.contractTrace = buildWatchCycleContractTrace({
|
|
315
|
+
handoff,
|
|
316
|
+
watchTimeoutPolicy: result.watchTimeoutPolicy ?? null,
|
|
317
|
+
cycleDisposition: result.cycleDisposition,
|
|
318
|
+
ciWatchArgs,
|
|
319
|
+
ciWatchStatus: ciWatch.status,
|
|
320
|
+
});
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
270
323
|
if (handoff.action !== "watch") {
|
|
271
324
|
result.contractTrace = buildWatchCycleContractTrace({
|
|
272
325
|
handoff,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
3
3
|
import { runChild as _runChild } from "../_cli-primitives.mjs";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
4
5
|
|
|
5
|
-
const USAGE = `Usage: dev-loops
|
|
6
|
+
const USAGE = `Usage: dev-loops queue add --repo <owner/name> --project <number|id> --item <number>
|
|
7
|
+
dev-loops project add … (back-compat alias for "queue add")
|
|
6
8
|
|
|
7
9
|
Add an existing issue or PR to a GitHub Projects V2 board.
|
|
8
10
|
|
|
@@ -10,7 +12,8 @@ Options:
|
|
|
10
12
|
--repo <owner/name> Required. Repository containing the issue/PR.
|
|
11
13
|
--project <number|id> Required. Project number (integer) or node ID.
|
|
12
14
|
--item <number> Required. Issue or PR number to add.
|
|
13
|
-
--
|
|
15
|
+
--column <name> Initial Status column (default: "Backlog").
|
|
16
|
+
--status <name> Back-compat alias for --column.
|
|
14
17
|
--help, -h Show this help.
|
|
15
18
|
|
|
16
19
|
Output (stdout):
|
|
@@ -23,52 +26,75 @@ Exit codes:
|
|
|
23
26
|
3 — project, field, column, or issue/PR not found
|
|
24
27
|
`.trim();
|
|
25
28
|
|
|
26
|
-
|
|
29
|
+
function parseCliArgs(argv) {
|
|
30
|
+
const parseError = (message) => Object.assign(new Error(message), { usage: USAGE });
|
|
31
|
+
const requireValue = (token, message) => {
|
|
32
|
+
const v = token.value;
|
|
33
|
+
if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
|
|
34
|
+
throw parseError(message);
|
|
35
|
+
}
|
|
36
|
+
return v;
|
|
37
|
+
};
|
|
27
38
|
|
|
28
|
-
function parseArgs(argv) {
|
|
29
39
|
const args = {};
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
const { tokens } = parseArgs({
|
|
41
|
+
args: [...argv],
|
|
42
|
+
options: {
|
|
43
|
+
repo: { type: "string" },
|
|
44
|
+
project: { type: "string" },
|
|
45
|
+
item: { type: "string" },
|
|
46
|
+
column: { type: "string" },
|
|
47
|
+
status: { type: "string" },
|
|
48
|
+
help: { type: "boolean", short: "h" },
|
|
49
|
+
},
|
|
50
|
+
allowPositionals: true,
|
|
51
|
+
strict: false,
|
|
52
|
+
tokens: true,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
for (const token of tokens) {
|
|
56
|
+
if (token.kind === "positional") {
|
|
57
|
+
throw Object.assign(new Error(`Unexpected argument: ${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
|
|
37
58
|
}
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
if (token.kind !== "option") {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
switch (token.name) {
|
|
63
|
+
case "help":
|
|
64
|
+
if (token.value !== undefined) {
|
|
65
|
+
throw Object.assign(new Error(`Unknown flag: ${token.rawName}=${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
|
|
66
|
+
}
|
|
67
|
+
args.help = true;
|
|
68
|
+
break;
|
|
69
|
+
case "repo":
|
|
70
|
+
args.repo = requireValue(token, "--repo requires a value (owner/name)");
|
|
71
|
+
break;
|
|
72
|
+
case "project":
|
|
73
|
+
args.project = requireValue(token, "--project requires a value (number or node ID)");
|
|
74
|
+
break;
|
|
75
|
+
case "item": {
|
|
76
|
+
const raw = requireValue(token, "--item requires a value (number)");
|
|
77
|
+
const val = Number(raw);
|
|
78
|
+
if (!Number.isInteger(val) || val < 1) {
|
|
79
|
+
throw Object.assign(
|
|
80
|
+
new Error(`--item must be a positive integer, got "${raw}"`),
|
|
81
|
+
{ code: "INVALID_ITEM" },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
args.item = val;
|
|
85
|
+
break;
|
|
63
86
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
case "column":
|
|
88
|
+
args.column = requireValue(token, "--column requires a value");
|
|
89
|
+
break;
|
|
90
|
+
case "status":
|
|
91
|
+
// Back-compat alias for --column (issue #912). Kept separate so a
|
|
92
|
+
// conflicting `--column X --status Y` is rejected rather than silently
|
|
93
|
+
// resolved by argv order.
|
|
94
|
+
args.status = requireValue(token, "--status requires a value");
|
|
95
|
+
break;
|
|
96
|
+
default:
|
|
97
|
+
throw Object.assign(new Error(`Unknown flag: ${token.rawName}`), { code: "INVALID_ARGS", usage: USAGE });
|
|
72
98
|
}
|
|
73
99
|
}
|
|
74
100
|
return args;
|
|
@@ -358,9 +384,15 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
358
384
|
if (!Number.isInteger(itemNumber) || itemNumber < 1) {
|
|
359
385
|
throw Object.assign(new Error("--item is required and must be a positive integer"), { code: "INVALID_ITEM" });
|
|
360
386
|
}
|
|
361
|
-
|
|
387
|
+
if (args.column != null && args.status != null && args.column.trim() !== args.status.trim()) {
|
|
388
|
+
throw Object.assign(
|
|
389
|
+
new Error(`Conflicting --column ("${args.column}") and --status ("${args.status}") — pass only one (prefer --column).`),
|
|
390
|
+
{ code: "INVALID_ARGS", usage: USAGE },
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
const targetStatus = (args.column ?? args.status ?? "Backlog").trim();
|
|
362
394
|
if (!targetStatus) {
|
|
363
|
-
throw Object.assign(new Error("--
|
|
395
|
+
throw Object.assign(new Error("--column must not be empty"), { code: "INVALID_STATUS" });
|
|
364
396
|
}
|
|
365
397
|
|
|
366
398
|
// 1. Resolve owner
|
|
@@ -499,7 +531,7 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
499
531
|
async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
|
|
500
532
|
let args;
|
|
501
533
|
try {
|
|
502
|
-
args =
|
|
534
|
+
args = parseCliArgs(argv);
|
|
503
535
|
} catch (err) {
|
|
504
536
|
stderr.write(`${formatCliError(err)}\n`);
|
|
505
537
|
process.exitCode = 1;
|
|
@@ -525,4 +557,4 @@ if (isDirectCliRun(import.meta.url)) {
|
|
|
525
557
|
});
|
|
526
558
|
}
|
|
527
559
|
|
|
528
|
-
export { main };
|
|
560
|
+
export { main, parseCliArgs };
|