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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
4
|
-
import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig } from "@dev-loops/core/config";
|
|
3
|
+
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText, sanitizeCopilotSummonTokens } from "../_core-helpers.mjs";
|
|
4
|
+
import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveGateAngleContract, resolveGateConfig, resolveRefinementConfig, resolveRejectForeignAngles } from "@dev-loops/core/config";
|
|
5
|
+
import { checkFanoutAngleCoverage } from "@dev-loops/core/loop/gate-fanin";
|
|
5
6
|
import { parseArgs } from "node:util";
|
|
6
7
|
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
7
8
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
@@ -72,6 +73,11 @@ Optional:
|
|
|
72
73
|
--gate <draft_gate|pre_approval_gate> Auto-resolved from coordination state
|
|
73
74
|
when omitted. Explicit gate is validated
|
|
74
75
|
against allowed coordination actions.
|
|
76
|
+
--lightweight This PR is light-dispatched (#1210):
|
|
77
|
+
resolve the Copilot round cap as
|
|
78
|
+
min(lightMode.maxCopilotRounds ?? 1,
|
|
79
|
+
refinement.maxCopilotRounds) instead of
|
|
80
|
+
refinement.maxCopilotRounds alone.
|
|
75
81
|
--findings-severity-counts <json> JSON object mapping severity to count
|
|
76
82
|
(e.g. '{"must-fix":0,"worth-fixing-now":0}').
|
|
77
83
|
Required for --verdict clean when
|
|
@@ -261,6 +267,7 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
|
|
|
261
267
|
"findings-severity-counts": { type: "string" },
|
|
262
268
|
"execution-mode": { type: "string" },
|
|
263
269
|
"inline-reason": { type: "string" },
|
|
270
|
+
lightweight: { type: "boolean" },
|
|
264
271
|
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
265
272
|
},
|
|
266
273
|
allowPositionals: true,
|
|
@@ -281,6 +288,7 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
|
|
|
281
288
|
findingsSeverityCounts: undefined,
|
|
282
289
|
executionMode: undefined,
|
|
283
290
|
inlineReason: undefined,
|
|
291
|
+
lightweight: false,
|
|
284
292
|
jq: undefined,
|
|
285
293
|
silent: false,
|
|
286
294
|
};
|
|
@@ -306,6 +314,10 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
|
|
|
306
314
|
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
307
315
|
continue;
|
|
308
316
|
}
|
|
317
|
+
if (token.name === "lightweight") {
|
|
318
|
+
options.lightweight = true;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
309
321
|
if (token.name === "gate") {
|
|
310
322
|
const gate = normalizeGateName(requireTokenValue(token, parseError));
|
|
311
323
|
if (!gate) {
|
|
@@ -742,7 +754,11 @@ export function renderGateReviewCommentBody({ gate, headSha, verdict, findingsSu
|
|
|
742
754
|
"",
|
|
743
755
|
`**Next action:** ${nextAction}`,
|
|
744
756
|
);
|
|
745
|
-
|
|
757
|
+
// Neutralize any bare @copilot/`/copilot`* tokens in the rendered body (gate
|
|
758
|
+
// evidence legitimately quotes the anti-summon rule, e.g. from a findings
|
|
759
|
+
// excerpt) so posting this comment can never arm request-copilot-review.mjs's
|
|
760
|
+
// anti-summon guard on a later request.
|
|
761
|
+
return sanitizeCopilotSummonTokens(lines.join("\n"));
|
|
746
762
|
}
|
|
747
763
|
function resolveRequestedHeadSha(requestedHeadSha, currentHeadSha) {
|
|
748
764
|
if (requestedHeadSha === currentHeadSha) {
|
|
@@ -949,13 +965,18 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
949
965
|
// loadPrGateCoordinationContext call will surface the real error.
|
|
950
966
|
}
|
|
951
967
|
}
|
|
952
|
-
|
|
968
|
+
// Thread the light-dispatch signal (#1210) so the context interpreter and the
|
|
969
|
+
// maxCopilotRounds resolution below both use the composed lightweight cap —
|
|
970
|
+
// the two must never disagree at the cap boundary (#1126).
|
|
971
|
+
const coordinationContext = await loadPrGateCoordinationContext({ repo: options.repo, pr: options.pr, lightweight: options.lightweight === true }, { env, ghCommand });
|
|
953
972
|
const evidence = coordinationContext.gateEvidence;
|
|
954
973
|
const canonicalHeadSha = resolveRequestedHeadSha(options.headSha, evidence.currentHeadSha);
|
|
955
974
|
const { config } = await loadDevLoopConfig({ repoRoot });
|
|
956
975
|
const draftGateConfig = resolveGateConfig(config, "draft");
|
|
957
976
|
const preApprovalGateConfig = resolveGateConfig(config, "preApproval");
|
|
958
|
-
const maxCopilotRounds =
|
|
977
|
+
const maxCopilotRounds = options.lightweight === true
|
|
978
|
+
? resolveEffectiveCopilotRoundCap(config, { lightweight: true })
|
|
979
|
+
: resolveRefinementConfig(config, "maxCopilotRounds");
|
|
959
980
|
// Root cause 2: detect internal-only PRs so the Copilot convergence requirement
|
|
960
981
|
// is suppressed. Docs-only / tooling-only PRs should go straight to pre_approval_gate
|
|
961
982
|
// without requiring an external Copilot review cycle.
|
|
@@ -982,6 +1003,10 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
982
1003
|
copilotReviewRoundCount: coordinationContext.snapshot?.copilotReviewRoundCount ?? 0,
|
|
983
1004
|
maxCopilotRounds,
|
|
984
1005
|
sameHeadCleanConverged: coordinationContext.interpretation.sameHeadCleanConverged,
|
|
1006
|
+
// Independent gate-ENTRY re-check (#1190): fed alongside (not derived from)
|
|
1007
|
+
// sameHeadCleanConverged, so an outstanding request on the current head refuses
|
|
1008
|
+
// RUN_PRE_APPROVAL_GATE even if sameHeadCleanConverged were somehow stale/wrong.
|
|
1009
|
+
copilotReviewRequestStatus: coordinationContext.snapshot?.copilotReviewRequestStatus ?? "none",
|
|
985
1010
|
draftGateRequireCi: draftGateConfig.requireCi,
|
|
986
1011
|
draftGate: coordinationContext.gateEvidence.draftGate,
|
|
987
1012
|
draftGateMarker: coordinationContext.gateEvidence.draftGateMarker,
|
|
@@ -1122,6 +1147,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1122
1147
|
// multi-line per-angle breakdown and the `**Findings summary:**` line carries a
|
|
1123
1148
|
// single-line digest (so the marker/parse contract still round-trips).
|
|
1124
1149
|
let structuredFindings = null;
|
|
1150
|
+
let rawFindingsInput = null;
|
|
1125
1151
|
if (options.findingsJson) {
|
|
1126
1152
|
let raw;
|
|
1127
1153
|
try {
|
|
@@ -1140,6 +1166,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1140
1166
|
const candidate = Array.isArray(parsed)
|
|
1141
1167
|
? parsed
|
|
1142
1168
|
: (Array.isArray(parsed?.angles) ? parsed.angles : (Array.isArray(parsed?.findings) ? parsed.findings : null));
|
|
1169
|
+
rawFindingsInput = candidate;
|
|
1143
1170
|
try {
|
|
1144
1171
|
structuredFindings = normalizeStructuredFindings(candidate);
|
|
1145
1172
|
} catch (err) {
|
|
@@ -1149,6 +1176,48 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1149
1176
|
throw new Error(`--findings-json "${options.findingsJson}" did not contain any renderable findings (expected a non-empty per-angle array of { angle, findings } entries, or a flat per-finding array of { severity, summary, angle? } entries)`);
|
|
1150
1177
|
}
|
|
1151
1178
|
}
|
|
1179
|
+
// Fan-out angle-coverage enforcement (fail closed): a fanout_fanin verdict's
|
|
1180
|
+
// structured per-angle results must cover every configured mandatory angle,
|
|
1181
|
+
// and (default) must not name an angle outside the gate's configured pool.
|
|
1182
|
+
// Only applies when structured per-angle results were actually supplied —
|
|
1183
|
+
// a free-text --findings-summary fanout_fanin verdict carries no per-angle
|
|
1184
|
+
// data to validate.
|
|
1185
|
+
if (structuredFindings && (options.executionMode ?? DEFAULT_EXECUTION_MODE) === "fanout_fanin") {
|
|
1186
|
+
// Angle-less entries would be bucketed under the synthetic `general` label
|
|
1187
|
+
// by normalization and then surface as a CONFUSING foreign-angle error.
|
|
1188
|
+
// Fail first with a dedicated message naming the real problem instead.
|
|
1189
|
+
const angleless = (rawFindingsInput ?? []).filter(
|
|
1190
|
+
(e) => !e || typeof e !== "object" || typeof e.angle !== "string" || e.angle.trim().length === 0,
|
|
1191
|
+
).length;
|
|
1192
|
+
if (angleless > 0) {
|
|
1193
|
+
throw new Error(
|
|
1194
|
+
`--findings-json for ${options.gate}: ${angleless} entr${angleless === 1 ? "y" : "ies"} lack a non-empty .angle — a fanout_fanin verdict must attribute every per-angle entry/finding to its review angle (use the nested [{ angle, verdict, findings }] shape, or add .angle to each flat finding)`,
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
const gateKey = options.gate === "draft_gate" ? "draft" : "preApproval";
|
|
1198
|
+
const { mandatoryAngles, pool } = resolveGateAngleContract(config, gateKey);
|
|
1199
|
+
const { missingMandatory, foreignAngles } = checkFanoutAngleCoverage(structuredFindings, {
|
|
1200
|
+
mandatoryAngles,
|
|
1201
|
+
pool,
|
|
1202
|
+
});
|
|
1203
|
+
if (missingMandatory.length > 0) {
|
|
1204
|
+
throw new Error(
|
|
1205
|
+
`--findings-json for ${options.gate} is missing mandatory angle(s): ${missingMandatory.join(", ")} (configured in gates.${gateKey}.mandatoryAngles; add a per-angle entry for each before posting a fanout_fanin verdict)`,
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
if (foreignAngles.length > 0) {
|
|
1209
|
+
const message = `--findings-json for ${options.gate} names angle(s) outside the configured pool: ${foreignAngles.join(", ")}`;
|
|
1210
|
+
if (resolveRejectForeignAngles(config)) {
|
|
1211
|
+
throw new Error(
|
|
1212
|
+
`${message} (add them to gates.${gateKey}.angles, or set gates.rejectForeignAngles: false to warn instead of fail)`,
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
// rejectForeignAngles: false is WARNING mode, not silence — one line per call.
|
|
1216
|
+
if (!options.silent) {
|
|
1217
|
+
process.stderr.write(`WARNING: ${message} (gates.rejectForeignAngles is false; recorded as a warning)\n`);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1152
1221
|
// --findings-json takes precedence; when structured findings are present, do not
|
|
1153
1222
|
// read --findings-file at all (avoids a spurious hard failure if a caller passes
|
|
1154
1223
|
// both and the file is missing/invalid even though it would be ignored anyway).
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
5
|
+
import { JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
6
|
+
|
|
7
|
+
const USAGE = `Usage: verify-briefing-prefixes.mjs --head-sha <sha> [--help]
|
|
8
|
+
Fan-in enforcement for GATE-EXEC-BRIEFING-PREFIX (docs/gate-review-sub-loop-contract.md):
|
|
9
|
+
fails closed when this gate-review round's per-scope reviewer sentinels (written by
|
|
10
|
+
verify-fresh-review-context.mjs --prefix-hash/--prefix-file) do not all record the SAME
|
|
11
|
+
invariant-briefing prefix hash. Deterministic and offline: reads only the sentinel files
|
|
12
|
+
already on disk under tmp/, keyed by the given head SHA.
|
|
13
|
+
|
|
14
|
+
Required:
|
|
15
|
+
--head-sha <sha> The FULL 40-char reviewed head SHA (git rev-parse HEAD); sentinels are read from
|
|
16
|
+
tmp/checkpoint-context-sentinel-<scope>-<headSha>.json.
|
|
17
|
+
|
|
18
|
+
Output (stdout, JSON):
|
|
19
|
+
{ "ok": true, "verified": true, "headSha": "...", "reviewerCount": <n>, "prefixHash": "..." }
|
|
20
|
+
{ "ok": true, "verified": true, "headSha": "...", "reviewerCount": 0, "reason": "no sentinels found for this round" }
|
|
21
|
+
{ "ok": true, "verified": false, "headSha": "...", "reviewerCount": <n>, "reason": "...", "missing": [...], "mismatched": [...] }
|
|
22
|
+
On error (stderr, JSON):
|
|
23
|
+
{ "ok": false, "error": "...", "usage": "..." }
|
|
24
|
+
${JQ_OUTPUT_USAGE}
|
|
25
|
+
Exit codes:
|
|
26
|
+
0 Verified: no sentinels found for this round (nothing to check), or every
|
|
27
|
+
sentinel records a hash and all hashes are identical (a single hashed
|
|
28
|
+
sentinel verifies — nothing to mismatch)
|
|
29
|
+
1 Fail closed: two or more sentinels record DIFFERENT prefix hashes, or ANY
|
|
30
|
+
sentinel for the round (even a lone one) records no prefix hash — a missing
|
|
31
|
+
hash is treated as a mismatch, never grandfathered
|
|
32
|
+
2 Usage or internal error, or invalid --jq filter
|
|
33
|
+
|
|
34
|
+
Caveat: rounds are keyed by head SHA only. Two different gates reviewed at the
|
|
35
|
+
SAME head share one sentinel namespace, so run this check per gate pass (the head
|
|
36
|
+
advances between gate passes per GATE-EXEC-REGATE-MANDATORY; never manually clear sentinels); legitimately different per-gate prefixes at an identical
|
|
37
|
+
head would otherwise flag as a mismatch (conservative fail-closed, never
|
|
38
|
+
fail-open).`.trim();
|
|
39
|
+
|
|
40
|
+
// Full 40-char SHA required: sentinel filenames embed the full `git rev-parse
|
|
41
|
+
// HEAD` value, so a short prefix would glob zero sentinels and read as a
|
|
42
|
+
// vacuous pass — fail closed on anything shorter instead.
|
|
43
|
+
const HEAD_SHA_RE = /^[0-9a-f]{40}$/i;
|
|
44
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
45
|
+
const SENTINEL_PREFIX = "checkpoint-context-sentinel-";
|
|
46
|
+
const parseError = buildParseError(USAGE);
|
|
47
|
+
|
|
48
|
+
function resolveFlagValue(argv, flag) {
|
|
49
|
+
const idx = argv.indexOf(flag);
|
|
50
|
+
if (idx === -1) return null;
|
|
51
|
+
const val = argv[idx + 1];
|
|
52
|
+
if (val === undefined || val === "" || (val.length > 0 && val[0] === "-")) {
|
|
53
|
+
return ""; // provided but missing/empty/flag-like
|
|
54
|
+
}
|
|
55
|
+
return val;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read every sentinel for the given round (head SHA) from `<tmpRoot>/`, extracting
|
|
60
|
+
* the reviewer scope (from the filename) and recorded `prefixHash` (from the JSON
|
|
61
|
+
* body, when present). A malformed/unreadable sentinel is still COUNTED, with
|
|
62
|
+
* `prefixHash: null` — downstream that reads as a missing hash and FAILS CLOSED,
|
|
63
|
+
* deliberately: a corrupt sentinel means the invariant-prefix proof cannot be
|
|
64
|
+
* verified for that reviewer, and silently dropping it would fail open.
|
|
65
|
+
* @param {string} tmpRoot
|
|
66
|
+
* @param {string} headSha — lowercase hex, already validated
|
|
67
|
+
* @returns {Promise<Array<{ scope: string, prefixHash: string|null }>>}
|
|
68
|
+
*/
|
|
69
|
+
async function readRoundSentinels(tmpRoot, headSha) {
|
|
70
|
+
const suffix = `-${headSha}.json`;
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = await readdir(tmpRoot, { withFileTypes: true });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err.code === "ENOENT") return [];
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
const matches = entries
|
|
79
|
+
.filter((e) => e.isFile() && e.name.startsWith(SENTINEL_PREFIX) && e.name.endsWith(suffix))
|
|
80
|
+
.map((e) => ({
|
|
81
|
+
file: e.name,
|
|
82
|
+
scope: e.name.slice(SENTINEL_PREFIX.length, -suffix.length),
|
|
83
|
+
}))
|
|
84
|
+
.filter((m) => m.scope.length > 0) // exclude the round-only (no-scope) sentinel name shape
|
|
85
|
+
// readdir order is filesystem-dependent; sort by scope so missing/mismatched
|
|
86
|
+
// output is deterministic across runs (this is a deterministic fan-in check).
|
|
87
|
+
.sort((a, b) => a.scope.localeCompare(b.scope));
|
|
88
|
+
const results = [];
|
|
89
|
+
for (const { file, scope } of matches) {
|
|
90
|
+
let prefixHash = null;
|
|
91
|
+
try {
|
|
92
|
+
const raw = await readFile(path.join(tmpRoot, file), "utf8");
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
// Only a well-formed sha256 counts as a recorded hash; anything else
|
|
95
|
+
// (corrupted/hand-edited value) is treated as missing so the round
|
|
96
|
+
// fails closed rather than comparing garbage.
|
|
97
|
+
if (parsed && typeof parsed.prefixHash === "string" && SHA256_RE.test(parsed.prefixHash.toLowerCase().trim())) {
|
|
98
|
+
prefixHash = parsed.prefixHash.toLowerCase().trim();
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// Malformed/unreadable sentinel: counted with prefixHash null so the
|
|
102
|
+
// evaluation fails closed on it (see the function doc comment).
|
|
103
|
+
}
|
|
104
|
+
results.push({ scope, prefixHash });
|
|
105
|
+
}
|
|
106
|
+
return results;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Pure comparison: given the round's sentinels, decide verified/reason/missing/mismatched.
|
|
111
|
+
* Exported for direct unit testing without touching the filesystem.
|
|
112
|
+
* @param {Array<{ scope: string, prefixHash: string|null }>} sentinels
|
|
113
|
+
* @returns {{ verified: boolean, reason?: string, missing?: string[], mismatched?: Array<{scope:string, prefixHash:string}> }}
|
|
114
|
+
*/
|
|
115
|
+
export function evaluateBriefingPrefixes(sentinels) {
|
|
116
|
+
if (sentinels.length === 0) {
|
|
117
|
+
return { verified: true, reason: "no sentinels found for this round" };
|
|
118
|
+
}
|
|
119
|
+
// A hashless sentinel fails closed even when it is the ONLY sentinel (a
|
|
120
|
+
// one-angle Phase-5 retry round is a real case): "never grandfathered" means
|
|
121
|
+
// the invariant-prefix proof must exist for every reviewer, not just when a
|
|
122
|
+
// sibling exists to compare against. A single HASHED sentinel stays verified —
|
|
123
|
+
// with one recorded hash there is nothing to mismatch.
|
|
124
|
+
const missing = sentinels.filter((s) => s.prefixHash === null).map((s) => s.scope);
|
|
125
|
+
if (missing.length > 0) {
|
|
126
|
+
return {
|
|
127
|
+
verified: false,
|
|
128
|
+
reason: `${missing.length} of ${sentinels.length} reviewer sentinel(s) for this round have no recorded prefix hash — the invariant-briefing-prefix proof (GATE-EXEC-BRIEFING-PREFIX) was never established for them. Missing hashes are treated as a mismatch, not grandfathered in.`,
|
|
129
|
+
missing,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const distinct = new Map();
|
|
133
|
+
for (const { scope, prefixHash } of sentinels) {
|
|
134
|
+
if (!distinct.has(prefixHash)) distinct.set(prefixHash, []);
|
|
135
|
+
distinct.get(prefixHash).push(scope);
|
|
136
|
+
}
|
|
137
|
+
if (distinct.size > 1) {
|
|
138
|
+
const mismatched = sentinels.map(({ scope, prefixHash }) => ({ scope, prefixHash }));
|
|
139
|
+
return {
|
|
140
|
+
verified: false,
|
|
141
|
+
reason: `Reviewer sentinels for this round recorded ${distinct.size} DIFFERENT invariant-briefing prefix hashes — the seeded briefings were not byte-identical (GATE-EXEC-BRIEFING-PREFIX).`,
|
|
142
|
+
mismatched,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return { verified: true, prefixHash: sentinels[0].prefixHash };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function main(argv = process.argv.slice(2), { tmpRoot = path.join(process.cwd(), "tmp") } = {}) {
|
|
149
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
150
|
+
process.stdout.write(`${USAGE}\n`);
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
const headShaArg = resolveFlagValue(argv, "--head-sha");
|
|
154
|
+
if (headShaArg === null || headShaArg === "" || !HEAD_SHA_RE.test(headShaArg)) {
|
|
155
|
+
process.stderr.write(`${formatCliError(
|
|
156
|
+
parseError(`--head-sha is required and must be the FULL 40-character hex head SHA (short prefixes would match zero sentinels and pass vacuously)${headShaArg ? ` (got ${JSON.stringify(headShaArg)})` : ""}.`)
|
|
157
|
+
)}\n`);
|
|
158
|
+
return 2;
|
|
159
|
+
}
|
|
160
|
+
const headSha = headShaArg.toLowerCase();
|
|
161
|
+
const jqArg = resolveFlagValue(argv, "--jq");
|
|
162
|
+
if (jqArg === "") {
|
|
163
|
+
process.stderr.write(`${formatCliError(parseError("Invalid --jq value: must be non-empty."))}\n`);
|
|
164
|
+
return 2;
|
|
165
|
+
}
|
|
166
|
+
const jq = jqArg === null ? undefined : jqArg;
|
|
167
|
+
const silent = argv.includes("--silent") || argv.includes("-s");
|
|
168
|
+
|
|
169
|
+
const sentinels = await readRoundSentinels(tmpRoot, headSha);
|
|
170
|
+
const verdict = evaluateBriefingPrefixes(sentinels);
|
|
171
|
+
const payload = {
|
|
172
|
+
ok: true,
|
|
173
|
+
verified: verdict.verified,
|
|
174
|
+
headSha,
|
|
175
|
+
reviewerCount: sentinels.length,
|
|
176
|
+
...(verdict.reason ? { reason: verdict.reason } : {}),
|
|
177
|
+
...(verdict.missing ? { missing: verdict.missing } : {}),
|
|
178
|
+
...(verdict.mismatched ? { mismatched: verdict.mismatched } : {}),
|
|
179
|
+
...(verdict.prefixHash ? { prefixHash: verdict.prefixHash } : {}),
|
|
180
|
+
};
|
|
181
|
+
return emitResult(payload, { jq, silent, ok: verdict.verified });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
185
|
+
try {
|
|
186
|
+
process.exitCode = await main();
|
|
187
|
+
} catch (err) {
|
|
188
|
+
process.stderr.write(`${formatCliError(err)}\n`);
|
|
189
|
+
process.exitCode = 2;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { buildParseError, isDirectCliRun, formatCliError } from "../_core-helpers.mjs";
|
|
6
7
|
import { JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
7
8
|
const USAGE = `Usage: verify-fresh-review-context.mjs [--help] [--scope <name>] [--context-path <path>]
|
|
9
|
+
[--prefix-hash <sha256>|--prefix-file <path>]
|
|
8
10
|
Verify that the current scoped-reviewer session has fresh context.
|
|
9
11
|
|
|
10
12
|
"Fresh" means the reviewer's context is the neutral gate-context builder
|
|
@@ -45,6 +47,19 @@ Options:
|
|
|
45
47
|
means either a stale/isolated checkout or a skipped
|
|
46
48
|
preamble, and the reviewer must refuse to proceed
|
|
47
49
|
rather than silently reviewing without seeded context.
|
|
50
|
+
--prefix-hash <sha256> A 64-character hex SHA-256 digest (case-insensitive,
|
|
51
|
+
normalized to lowercase) of the
|
|
52
|
+
invariant briefing block this reviewer was seeded with
|
|
53
|
+
(GATE-EXEC-BRIEFING-PREFIX in
|
|
54
|
+
docs/gate-review-sub-loop-contract.md). Recorded on the
|
|
55
|
+
reviewer's sentinel so \`verify-briefing-prefixes.mjs\` can
|
|
56
|
+
fail closed when reviewers of the same gate pass were
|
|
57
|
+
seeded with different invariant blocks. Mutually exclusive
|
|
58
|
+
with --prefix-file.
|
|
59
|
+
--prefix-file <path> Path to the invariant briefing block text; the tool
|
|
60
|
+
hashes its raw bytes (sha256) and records the digest same
|
|
61
|
+
as --prefix-hash. Fails closed (exit 1) if the file is
|
|
62
|
+
missing. Mutually exclusive with --prefix-hash.
|
|
48
63
|
Output (stdout, JSON):
|
|
49
64
|
{ "ok": true, "fresh": true, "sentinelCreated": true, "round": "<headSha|null>" }
|
|
50
65
|
{ "ok": true, "fresh": true, "sentinelCreated": true, "round": "...", "gateContextPath": "...", "gateContextPresent": true }
|
|
@@ -57,9 +72,12 @@ Exit codes:
|
|
|
57
72
|
0 Clean (first run)
|
|
58
73
|
1 Refuse to review: contaminated (prior session detected), OR (with
|
|
59
74
|
--context-path) the seeded gate-context artifact is missing or resolves
|
|
60
|
-
outside the reviewer's working directory
|
|
61
|
-
|
|
75
|
+
outside the reviewer's working directory, OR (with --prefix-file) the
|
|
76
|
+
prefix file is missing
|
|
77
|
+
2 Usage or internal error, invalid --jq filter, or invalid/conflicting
|
|
78
|
+
--prefix-hash/--prefix-file`.trim();
|
|
62
79
|
const VALID_SCOPE_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
|
80
|
+
const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
|
|
63
81
|
const parseError = buildParseError(USAGE);
|
|
64
82
|
// Resolve a `--flag <value>` argument. Returns null when the flag is absent,
|
|
65
83
|
// "" when it is present but the value is missing/empty/flag-like (a following
|
|
@@ -90,6 +108,12 @@ function resolveValidatedScope(argv) {
|
|
|
90
108
|
function resolveContextPath(argv) {
|
|
91
109
|
return resolveFlagValue(argv, "--context-path");
|
|
92
110
|
}
|
|
111
|
+
function resolvePrefixHash(argv) {
|
|
112
|
+
return resolveFlagValue(argv, "--prefix-hash");
|
|
113
|
+
}
|
|
114
|
+
function resolvePrefixFile(argv) {
|
|
115
|
+
return resolveFlagValue(argv, "--prefix-file");
|
|
116
|
+
}
|
|
93
117
|
// Round = the current head SHA, so a retry on a new head gets a fresh key while
|
|
94
118
|
// a same-head re-entry collides and fails closed. `git rev-parse HEAD` yields the
|
|
95
119
|
// same full SHA on every invocation for a given head, so the key is deterministic
|
|
@@ -145,6 +169,32 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
145
169
|
)}\n`);
|
|
146
170
|
return 2;
|
|
147
171
|
}
|
|
172
|
+
const prefixHashArg = resolvePrefixHash(argv);
|
|
173
|
+
if (prefixHashArg === "") {
|
|
174
|
+
process.stderr.write(`${formatCliError(
|
|
175
|
+
parseError("Invalid --prefix-hash value: must be non-empty.")
|
|
176
|
+
)}\n`);
|
|
177
|
+
return 2;
|
|
178
|
+
}
|
|
179
|
+
const prefixFileArg = resolvePrefixFile(argv);
|
|
180
|
+
if (prefixFileArg === "") {
|
|
181
|
+
process.stderr.write(`${formatCliError(
|
|
182
|
+
parseError("Invalid --prefix-file value: must be non-empty.")
|
|
183
|
+
)}\n`);
|
|
184
|
+
return 2;
|
|
185
|
+
}
|
|
186
|
+
if (prefixHashArg !== null && prefixFileArg !== null) {
|
|
187
|
+
process.stderr.write(`${formatCliError(
|
|
188
|
+
parseError("--prefix-hash and --prefix-file are mutually exclusive — pass at most one.")
|
|
189
|
+
)}\n`);
|
|
190
|
+
return 2;
|
|
191
|
+
}
|
|
192
|
+
if (prefixHashArg !== null && !SHA256_HEX_RE.test(prefixHashArg.trim().toLowerCase())) {
|
|
193
|
+
process.stderr.write(`${formatCliError(
|
|
194
|
+
parseError(`Invalid --prefix-hash value "${prefixHashArg}": must be a 64-character hex SHA-256 digest (case-insensitive).`)
|
|
195
|
+
)}\n`);
|
|
196
|
+
return 2;
|
|
197
|
+
}
|
|
148
198
|
const jqArg = resolveFlagValue(argv, "--jq");
|
|
149
199
|
if (jqArg === "") {
|
|
150
200
|
process.stderr.write(`${formatCliError(
|
|
@@ -195,6 +245,29 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
195
245
|
}, false);
|
|
196
246
|
}
|
|
197
247
|
}
|
|
248
|
+
// Resolve the invariant-briefing prefix hash (GATE-EXEC-BRIEFING-PREFIX) before
|
|
249
|
+
// sentinel creation, same ordering rationale as --context-path above: a failure
|
|
250
|
+
// here must not burn the scope sentinel.
|
|
251
|
+
let prefixHash = prefixHashArg !== null ? prefixHashArg.trim().toLowerCase() : null;
|
|
252
|
+
if (prefixHash === null && prefixFileArg !== null) {
|
|
253
|
+
let prefixFileBytes;
|
|
254
|
+
try {
|
|
255
|
+
prefixFileBytes = await readFile(path.resolve(process.cwd(), prefixFileArg));
|
|
256
|
+
} catch (err) {
|
|
257
|
+
// ANY read failure (ENOENT, EACCES, EPERM, ...) refuses the review in the
|
|
258
|
+
// normal fail-closed shape — an unreadable prefix file means the
|
|
259
|
+
// invariant-briefing proof cannot be established, which is an enforcement
|
|
260
|
+
// refusal, not a tool crash.
|
|
261
|
+
return finish({
|
|
262
|
+
ok: true,
|
|
263
|
+
fresh: false,
|
|
264
|
+
sentinelCreated: false,
|
|
265
|
+
round: round ?? null,
|
|
266
|
+
reason: `--prefix-file "${prefixFileArg}" unreadable (${err.code ?? "error"}) — cannot compute the invariant-briefing prefix hash (GATE-EXEC-BRIEFING-PREFIX).`,
|
|
267
|
+
}, false);
|
|
268
|
+
}
|
|
269
|
+
prefixHash = createHash("sha256").update(prefixFileBytes).digest("hex");
|
|
270
|
+
}
|
|
198
271
|
const sentinelPath = path.resolve(process.cwd(), sentinelRelative(scope, round));
|
|
199
272
|
try {
|
|
200
273
|
await mkdir(path.dirname(sentinelPath), { recursive: true });
|
|
@@ -217,6 +290,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
217
290
|
pid: process.pid,
|
|
218
291
|
...(scope ? { scope } : {}),
|
|
219
292
|
...(round ? { round } : {}),
|
|
293
|
+
...(prefixHash ? { prefixHash } : {}),
|
|
220
294
|
};
|
|
221
295
|
try {
|
|
222
296
|
await writeFile(sentinelPath, JSON.stringify(sentinel, null, 2) + "\n", {
|
|
@@ -242,6 +316,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
242
316
|
sentinelCreated: true,
|
|
243
317
|
round: round ?? null,
|
|
244
318
|
...(contextPathArg !== null ? { gateContextPath: contextPathArg, gateContextPresent: true } : {}),
|
|
319
|
+
...(prefixHash ? { prefixHash } : {}),
|
|
245
320
|
}, true);
|
|
246
321
|
}
|
|
247
322
|
if (isDirectCliRun(import.meta.url)) {
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
4
|
+
import { parseNonNegativeInteger, parsePositiveInteger, parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
|
|
5
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_POLL_INTERVAL_MS,
|
|
9
|
+
COPILOT_REVIEW_WAIT_TIMEOUT_MS,
|
|
10
|
+
} from "@dev-loops/core/loop/policy-constants";
|
|
11
|
+
// Reuse probe-ci-status.mjs's watch engine verbatim (polling, heartbeats, the
|
|
12
|
+
// not-yet-registered-check grace/race guard, head-change detection) instead of
|
|
13
|
+
// re-implementing it. This wrapper only adds seconds-based flags and a direct
|
|
14
|
+
// process-exit-code contract for shell/scripted callers.
|
|
15
|
+
import { watchCiStatus } from "./probe-ci-status.mjs";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_TIMEOUT_SECONDS = Math.floor(COPILOT_REVIEW_WAIT_TIMEOUT_MS / 1000);
|
|
18
|
+
const DEFAULT_POLL_SECONDS = Math.floor(DEFAULT_POLL_INTERVAL_MS / 1000);
|
|
19
|
+
|
|
20
|
+
const USAGE = `Usage: wait-pr-checks.mjs --repo <owner/name> --pr <number> [--timeout <seconds>] [--poll <seconds>]
|
|
21
|
+
Block until the current head SHA's CI checks/statuses settle, or the wait
|
|
22
|
+
budget elapses. Replaces hand-rolled \`until [ "$(gh pr checks | grep -c pending)" = 0 ]\`
|
|
23
|
+
shell loops with a single deterministic call.
|
|
24
|
+
Required:
|
|
25
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
26
|
+
--pr <number> Pull request number
|
|
27
|
+
Optional:
|
|
28
|
+
--timeout <seconds> Total wait budget (default ${DEFAULT_TIMEOUT_SECONDS}; 0 = single
|
|
29
|
+
immediate check, no wait)
|
|
30
|
+
--poll <seconds> Delay between polls (default ${DEFAULT_POLL_SECONDS})
|
|
31
|
+
Not-yet-registered-check race guard:
|
|
32
|
+
A freshly pushed head with zero registered checks does NOT settle green on
|
|
33
|
+
the first poll — a provider may post its first check a beat after the push.
|
|
34
|
+
The watcher requires either an observed check/status, or a stable empty
|
|
35
|
+
result across two consecutive polls, before treating the head as genuinely
|
|
36
|
+
check-less. A repo with no CI at all still settles after that grace instead
|
|
37
|
+
of hanging to timeout.
|
|
38
|
+
Output (stdout, JSON, the final per-check summary):
|
|
39
|
+
{ "ok": true, "status": "success"|"failure"|"pending"|"timeout"|"changed",
|
|
40
|
+
"settled": bool, "ciStatus": "success"|"failure"|"pending"|"none",
|
|
41
|
+
"failedChecks": [{ "name": "...", "conclusion"?: "..." }], "headSha": "...", "attempts": N }
|
|
42
|
+
"changed" means the head SHA advanced during the wait; re-baseline and re-run.
|
|
43
|
+
Diagnostic output (stderr):
|
|
44
|
+
{ "ok": true, "type": "watch_heartbeat", "elapsedMs": N, "totalBudgetMs": N, "poll": N, "maxPolls": N }
|
|
45
|
+
{ "ok": false, "error": "...", "usage"?: "..." }
|
|
46
|
+
${JQ_OUTPUT_USAGE}
|
|
47
|
+
Exit codes (default output, no --jq/--silent):
|
|
48
|
+
0 Green (status "success")
|
|
49
|
+
1 Red (status "failure"), or an argument/gh/runtime error
|
|
50
|
+
2 Not settled (status "timeout"/"changed"/"pending")
|
|
51
|
+
With --jq/--silent, the standard jq-output contract above applies instead
|
|
52
|
+
(0/1 by result truthiness; 2 reserved for an invalid --jq filter).`.trim();
|
|
53
|
+
|
|
54
|
+
const parseError = buildParseError(USAGE);
|
|
55
|
+
|
|
56
|
+
export function parseWaitPrChecksCliArgs(argv) {
|
|
57
|
+
const { tokens } = parseArgs({
|
|
58
|
+
args: [...argv],
|
|
59
|
+
options: {
|
|
60
|
+
help: { type: "boolean", short: "h" },
|
|
61
|
+
repo: { type: "string" },
|
|
62
|
+
pr: { type: "string" },
|
|
63
|
+
timeout: { type: "string" },
|
|
64
|
+
poll: { type: "string" },
|
|
65
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
66
|
+
},
|
|
67
|
+
allowPositionals: true,
|
|
68
|
+
strict: false,
|
|
69
|
+
tokens: true,
|
|
70
|
+
});
|
|
71
|
+
const options = {
|
|
72
|
+
help: false,
|
|
73
|
+
repo: undefined,
|
|
74
|
+
pr: undefined,
|
|
75
|
+
timeoutMs: DEFAULT_TIMEOUT_SECONDS * 1000,
|
|
76
|
+
pollIntervalMs: DEFAULT_POLL_SECONDS * 1000,
|
|
77
|
+
};
|
|
78
|
+
for (const token of tokens) {
|
|
79
|
+
if (token.kind === "positional") {
|
|
80
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
81
|
+
}
|
|
82
|
+
if (token.kind !== "option") {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (token.name === "help") {
|
|
86
|
+
options.help = true;
|
|
87
|
+
return options;
|
|
88
|
+
}
|
|
89
|
+
if (token.name === "repo") {
|
|
90
|
+
options.repo = requireTokenValue(token, parseError).trim();
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (token.name === "pr") {
|
|
94
|
+
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (token.name === "timeout") {
|
|
98
|
+
// Canonical-digits validation (shared /^\d+$/ primitives): rejects
|
|
99
|
+
// JS-coercible spellings like 1e3, 0x10, "1.0", or " 5 ".
|
|
100
|
+
options.timeoutMs = parseNonNegativeInteger(requireTokenValue(token, parseError), "--timeout", parseError) * 1000;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (token.name === "poll") {
|
|
104
|
+
options.pollIntervalMs = parsePositiveInteger(requireTokenValue(token, parseError), "--poll", parseError) * 1000;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
108
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
109
|
+
}
|
|
110
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
111
|
+
throw parseError("Waiting on PR checks requires both --repo <owner/name> and --pr <number>");
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
parseRepoSlug(options.repo);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
117
|
+
}
|
|
118
|
+
return options;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Direct process-exit-code contract for shell/scripted callers — this is the
|
|
122
|
+
// tool's reason to exist alongside `dev-loops loop watch-ci`, whose CLI always
|
|
123
|
+
// exits 0 and expects callers to branch on the JSON `status` field instead.
|
|
124
|
+
export function exitCodeForWaitResult(result) {
|
|
125
|
+
if (result.status === "success") return 0;
|
|
126
|
+
if (result.status === "failure") return 1;
|
|
127
|
+
return 2;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function runCli(
|
|
131
|
+
argv = process.argv.slice(2),
|
|
132
|
+
{
|
|
133
|
+
stdout = process.stdout,
|
|
134
|
+
stderr = process.stderr,
|
|
135
|
+
env = process.env,
|
|
136
|
+
ghCommand = "gh",
|
|
137
|
+
delayImpl = undefined,
|
|
138
|
+
now = undefined,
|
|
139
|
+
} = {},
|
|
140
|
+
) {
|
|
141
|
+
const options = parseWaitPrChecksCliArgs(argv);
|
|
142
|
+
if (options.help) {
|
|
143
|
+
stdout.write(`${USAGE}\n`);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
const result = await watchCiStatus(
|
|
147
|
+
{ repo: options.repo, pr: options.pr, timeoutMs: options.timeoutMs, pollIntervalMs: options.pollIntervalMs },
|
|
148
|
+
{
|
|
149
|
+
env,
|
|
150
|
+
ghCommand,
|
|
151
|
+
...(delayImpl ? { delayImpl } : {}),
|
|
152
|
+
...(now ? { now } : {}),
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
if (options.jq !== undefined || options.silent) {
|
|
156
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr, ok: result.status === "success" });
|
|
157
|
+
}
|
|
158
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
159
|
+
return exitCodeForWaitResult(result);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
163
|
+
runCli().then((code) => {
|
|
164
|
+
if (typeof code === "number") {
|
|
165
|
+
process.exitCode = code;
|
|
166
|
+
}
|
|
167
|
+
}).catch((error) => {
|
|
168
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
});
|
|
171
|
+
}
|