dev-loops 0.6.0 → 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 +8 -7
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/refiner.md +2 -2
- package/.claude/agents/review.md +4 -3
- package/.claude/commands/{auto.md → loop-auto.md} +1 -1
- package/.claude/commands/loop-continue.md +15 -0
- package/.claude/commands/loop-enqueue.md +22 -0
- package/.claude/commands/loop-grill.md +17 -0
- package/.claude/commands/{info.md → loop-info.md} +2 -2
- package/.claude/commands/loop-queue-status.md +24 -0
- package/.claude/commands/{start-spike.md → loop-start-spike.md} +2 -2
- package/.claude/commands/{start.md → loop-start.md} +1 -1
- package/.claude/commands/{status.md → loop-status.md} +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +333 -29
- package/.claude/hooks/_hook-decisions.mjs +138 -15
- package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
- package/.claude/skills/copilot-pr-followup/SKILL.md +60 -34
- package/.claude/skills/dev-loop/SKILL.md +16 -12
- package/.claude/skills/docs/acceptance-criteria-verification.md +8 -3
- package/.claude/skills/docs/anti-patterns.md +5 -3
- package/.claude/skills/docs/artifact-authority-contract.md +30 -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 +19 -15
- package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
- 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 +26 -19
- package/.claude/skills/docs/local-planning-flow.md +1 -1
- package/.claude/skills/docs/local-planning-worked-example.md +1 -1
- package/.claude/skills/docs/merge-preconditions.md +30 -12
- package/.claude/skills/docs/plan-file-contract.md +1 -1
- package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
- package/.claude/skills/docs/public-dev-loop-contract.md +60 -44
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +106 -79
- 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/ui-e2e-scoping-step.md +32 -0
- package/.claude/skills/docs/validation-policy.md +4 -5
- package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
- package/.claude/skills/local-implementation/SKILL.md +62 -145
- package/.claude/skills/loop-grill/SKILL.md +163 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +101 -0
- package/README.md +9 -9
- package/agents/dev-loop.agent.md +11 -10
- package/agents/developer.agent.md +1 -1
- package/agents/docs.agent.md +1 -1
- package/agents/fixer.agent.md +2 -1
- package/agents/quality.agent.md +1 -1
- package/agents/refiner.agent.md +3 -3
- package/agents/review.agent.md +5 -4
- package/cli/index.mjs +35 -42
- package/extension/README.md +4 -4
- package/extension/checks.ts +1 -5
- package/extension/harness-types.ts +1 -0
- package/extension/index.ts +6 -0
- package/extension/pi-extension-adapter.ts +2 -0
- package/extension/presentation.ts +7 -19
- package/package.json +12 -12
- 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/capture-review-threads.mjs +2 -9
- package/scripts/github/comment-issue.mjs +2 -9
- package/scripts/github/create-label.mjs +133 -0
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +259 -18
- package/scripts/github/detect-linked-issue-pr.mjs +22 -6
- package/scripts/github/edit-pr.mjs +259 -0
- package/scripts/github/fetch-ci-logs.mjs +2 -9
- package/scripts/github/list-issues.mjs +2 -9
- package/scripts/github/list-review-threads.mjs +277 -0
- package/scripts/github/manage-sub-issues.mjs +46 -10
- package/scripts/github/offer-human-handoff.mjs +8 -5
- package/scripts/github/post-gate-findings.mjs +20 -5
- package/scripts/github/probe-ci-status.mjs +8 -2
- package/scripts/github/probe-copilot-review.mjs +21 -14
- package/scripts/github/ready-for-review.mjs +26 -8
- package/scripts/github/reconcile-draft-gate.mjs +7 -3
- package/scripts/github/reply-resolve-review-thread.mjs +16 -5
- package/scripts/github/reply-resolve-review-threads.mjs +69 -7
- package/scripts/github/request-copilot-review.mjs +98 -26
- package/scripts/github/resolve-handoff-candidates.mjs +7 -3
- package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
- package/scripts/github/stage-reviewer-draft.mjs +10 -2
- package/scripts/github/tick-verified-checkboxes.mjs +202 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +105 -15
- package/scripts/github/verify-briefing-prefixes.mjs +191 -0
- package/scripts/github/verify-fresh-review-context.mjs +226 -32
- package/scripts/github/view-pr.mjs +150 -0
- package/scripts/github/wait-pr-checks.mjs +171 -0
- package/scripts/github/write-gate-context.mjs +458 -65
- package/scripts/github/write-gate-findings-log.mjs +128 -2
- package/scripts/lib/jq-output.mjs +18 -0
- package/scripts/loop/_post-convergence-change.mjs +211 -0
- package/scripts/loop/_pr-runner-coordination.mjs +70 -0
- package/scripts/loop/_repo-root-resolver.mjs +47 -0
- package/scripts/loop/build-handoff-envelope.mjs +14 -4
- package/scripts/loop/check-retro-tooling.mjs +14 -3
- package/scripts/loop/checkpoint-contract.mjs +7 -4
- package/scripts/loop/cleanup-worktree.mjs +12 -3
- package/scripts/loop/conductor-monitor.mjs +12 -18
- package/scripts/loop/copilot-pr-handoff.mjs +162 -14
- package/scripts/loop/debt-remediate.mjs +24 -12
- package/scripts/loop/detect-change-scope.mjs +38 -9
- package/scripts/loop/detect-copilot-loop-state.mjs +34 -8
- package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
- package/scripts/loop/detect-internal-only-pr.mjs +8 -2
- package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +184 -69
- package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
- package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
- package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
- package/scripts/loop/ensure-worktree.mjs +8 -3
- package/scripts/loop/info.mjs +12 -4
- package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
- package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
- package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
- package/scripts/loop/inspect-run-viewer.mjs +67 -4
- package/scripts/loop/inspect-run.mjs +8 -2
- package/scripts/loop/outer-loop.mjs +8 -4
- package/scripts/loop/pr-runner-coordination.mjs +2 -9
- package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
- package/scripts/loop/pre-flight-gate.mjs +9 -9
- package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
- package/scripts/loop/provision-worktree.mjs +74 -3
- package/scripts/loop/resolve-dev-loop-startup.mjs +216 -10
- package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
- package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
- package/scripts/loop/run-conductor-cycle.mjs +8 -2
- package/scripts/loop/run-queue.mjs +18 -9
- package/scripts/loop/run-refinement-audit.mjs +8 -9
- package/scripts/loop/run-watch-cycle.mjs +2 -9
- package/scripts/loop/sanctioned-commands.mjs +106 -0
- package/scripts/loop/steer-loop.mjs +29 -16
- package/scripts/loop/validate-pr-body-spec.mjs +223 -0
- package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
- package/scripts/pages/build-site.mjs +59 -8
- package/scripts/pages/build-state-atlas.mjs +443 -0
- package/scripts/projects/_resolve-project.mjs +1 -0
- package/scripts/projects/add-queue-item.mjs +60 -54
- package/scripts/projects/archive-done-items.mjs +49 -84
- package/scripts/projects/ensure-queue-board.mjs +10 -36
- package/scripts/projects/list-queue-items.mjs +59 -382
- package/scripts/projects/move-queue-item.mjs +21 -449
- package/scripts/projects/reconcile-queue.mjs +253 -0
- package/scripts/projects/reorder-queue-item.mjs +43 -68
- package/scripts/projects/resolve-active-board-item.mjs +108 -46
- package/scripts/projects/sync-item-status.mjs +15 -10
- package/scripts/refine/_refine-helpers.mjs +21 -5
- package/scripts/refine/exit-spike.mjs +18 -8
- package/scripts/refine/promote-plan.mjs +22 -16
- package/scripts/refine/prose-linkage-detector.mjs +1 -1
- package/scripts/refine/refine-plan-file.mjs +13 -4
- package/scripts/refine/refinement-completeness-checker.mjs +1 -1
- package/scripts/refine/scaffold-spike-file.mjs +4 -8
- package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
- package/scripts/refine/tree-integrity-validator.mjs +1 -1
- package/scripts/refine/validate-plan-file.mjs +1 -1
- package/scripts/refine/validate-spike-file.mjs +1 -1
- package/scripts/refine/verify.mjs +11 -6
- package/scripts/release/assert-core-dependency-version.mjs +123 -0
- package/scripts/release/extract-changelog-section.mjs +16 -2
- package/skills/copilot-pr-followup/SKILL.md +60 -34
- package/skills/dev-loop/SKILL.md +11 -7
- package/skills/docs/acceptance-criteria-verification.md +8 -3
- package/skills/docs/anti-patterns.md +5 -3
- package/skills/docs/artifact-authority-contract.md +30 -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 +19 -15
- package/skills/docs/cross-harness-regression-contract.md +60 -0
- 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 +26 -19
- package/skills/docs/local-planning-flow.md +1 -1
- package/skills/docs/local-planning-worked-example.md +1 -1
- package/skills/docs/merge-preconditions.md +30 -12
- package/skills/docs/plan-file-contract.md +1 -1
- package/skills/docs/pr-lifecycle-contract.md +44 -35
- package/skills/docs/public-dev-loop-contract.md +60 -44
- package/skills/docs/required-rules.json +149 -0
- package/skills/docs/retrospective-checkpoint-contract.md +106 -79
- 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/ui-e2e-scoping-step.md +32 -0
- package/skills/docs/validation-policy.md +4 -5
- package/skills/docs/workflow-handoff-contract.md +39 -0
- package/skills/local-implementation/SKILL.md +62 -145
- package/skills/loop-grill/SKILL.md +165 -0
- package/.claude/commands/continue.md +0 -15
- package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
- package/scripts/loop/conductor.mjs +0 -233
- package/scripts/loop/detect-stale-runner.mjs +0 -265
- package/scripts/loop/pre-push-main-guard.mjs +0 -117
|
@@ -1,8 +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
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
3
5
|
import path from "node:path";
|
|
4
6
|
import { buildParseError, isDirectCliRun, formatCliError } from "../_core-helpers.mjs";
|
|
5
|
-
|
|
7
|
+
import { JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
8
|
+
const USAGE = `Usage: verify-fresh-review-context.mjs [--help] [--scope <name>] [--context-path <path>]
|
|
9
|
+
[--prefix-hash <sha256>|--prefix-file <path>]
|
|
6
10
|
Verify that the current scoped-reviewer session has fresh context.
|
|
7
11
|
|
|
8
12
|
"Fresh" means the reviewer's context is the neutral gate-context builder
|
|
@@ -10,30 +14,76 @@ artifact (the build-once diff + adjacent-code bundle) plus its single review
|
|
|
10
14
|
angle, and explicitly NOT the main (orchestrating) agent's conversation/state
|
|
11
15
|
or a prior reviewer session's state. The injected neutral bundle is the
|
|
12
16
|
INTENDED seed and is NOT contamination; this guard detects main-agent /
|
|
13
|
-
cross-session state bleed by way of a per-(cwd, scope) sentinel: a first
|
|
14
|
-
a fresh session creates the sentinel and passes, a re-entry that finds an
|
|
15
|
-
existing sentinel fails closed. Seeding a reviewer with the
|
|
16
|
-
path/prompt, not a sentinel) never creates a sentinel, so it
|
|
17
|
-
false-positives as contaminated.
|
|
17
|
+
cross-session state bleed by way of a per-(cwd, scope, round) sentinel: a first
|
|
18
|
+
run in a fresh session creates the sentinel and passes, a re-entry that finds an
|
|
19
|
+
existing sentinel for the same round fails closed. Seeding a reviewer with the
|
|
20
|
+
neutral bundle (a path/prompt, not a sentinel) never creates a sentinel, so it
|
|
21
|
+
never false-positives as contaminated.
|
|
22
|
+
|
|
23
|
+
Sentinels are per review ROUND, keyed by the current head SHA (\`git rev-parse
|
|
24
|
+
HEAD\`). A retry at a new head naturally gets a fresh sentinel (no manual clear
|
|
25
|
+
step), while a same-scope + same-head re-entry still fails closed. When git is
|
|
26
|
+
unavailable the sentinel is keyed by scope only (legacy behavior).
|
|
18
27
|
Options:
|
|
19
28
|
--scope <name> Unique reviewer scope (e.g. "draft-gate-coverage").
|
|
20
29
|
Must be non-empty, containing only alphanumeric
|
|
21
30
|
characters and hyphens. When provided, the sentinel
|
|
22
31
|
is scoped so parallel reviewers in the same working
|
|
23
32
|
directory do not trigger false contamination.
|
|
33
|
+
--context-path <path> Path to the seeded gate-context artifact this
|
|
34
|
+
reviewer must be reading from (the build-once bundle
|
|
35
|
+
written by write-gate-context.mjs, e.g.
|
|
36
|
+
tmp/gate-context/<repo-slug>/pr-<N>/<gate>-<headSha>.json).
|
|
37
|
+
Must resolve to a path within the reviewer's working
|
|
38
|
+
directory; a path that resolves OUTSIDE cwd (an absolute
|
|
39
|
+
or ..-escaping path pointing at another worktree's bundle,
|
|
40
|
+
which would defeat the worktree-locality guard) fails
|
|
41
|
+
closed (exit 1).
|
|
42
|
+
When provided, also fails closed (exit 1) if the artifact
|
|
43
|
+
is missing (ENOENT) from the reviewer's cwd. Per-angle
|
|
44
|
+
gate reviewers must run in the PR's actual worktree/head
|
|
45
|
+
(never an isolated worktree) so this gitignored,
|
|
46
|
+
worktree-local artifact is present; a missing artifact
|
|
47
|
+
means either a stale/isolated checkout or a skipped
|
|
48
|
+
preamble, and the reviewer must refuse to proceed
|
|
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.
|
|
24
63
|
Output (stdout, JSON):
|
|
25
|
-
{ "ok": true, "fresh": true, "sentinelCreated": true }
|
|
26
|
-
{ "ok": true, "fresh":
|
|
64
|
+
{ "ok": true, "fresh": true, "sentinelCreated": true, "round": "<headSha|null>" }
|
|
65
|
+
{ "ok": true, "fresh": true, "sentinelCreated": true, "round": "...", "gateContextPath": "...", "gateContextPresent": true }
|
|
66
|
+
{ "ok": true, "fresh": false, "sentinelCreated": false, "round": "...", "reason": "..." }
|
|
67
|
+
{ "ok": true, "fresh": false, "sentinelCreated": false, "round": "...", "gateContextPath": "...", "gateContextPresent": false, "reason": "..." }
|
|
27
68
|
On error (stderr, JSON):
|
|
28
69
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
70
|
+
${JQ_OUTPUT_USAGE}
|
|
29
71
|
Exit codes:
|
|
30
72
|
0 Clean (first run)
|
|
31
|
-
1
|
|
32
|
-
|
|
73
|
+
1 Refuse to review: contaminated (prior session detected), OR (with
|
|
74
|
+
--context-path) the seeded gate-context artifact is missing or resolves
|
|
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();
|
|
33
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}$/;
|
|
34
81
|
const parseError = buildParseError(USAGE);
|
|
35
|
-
|
|
36
|
-
|
|
82
|
+
// Resolve a `--flag <value>` argument. Returns null when the flag is absent,
|
|
83
|
+
// "" when it is present but the value is missing/empty/flag-like (a following
|
|
84
|
+
// `-`-prefixed token, which must not be silently consumed), else the value.
|
|
85
|
+
function resolveFlagValue(argv, flag) {
|
|
86
|
+
const idx = argv.indexOf(flag);
|
|
37
87
|
if (idx === -1) return null;
|
|
38
88
|
const val = argv[idx + 1];
|
|
39
89
|
if (val === undefined || val === "" || (val.length > 0 && val[0] === "-")) {
|
|
@@ -41,6 +91,9 @@ function resolveScope(argv) {
|
|
|
41
91
|
}
|
|
42
92
|
return val;
|
|
43
93
|
}
|
|
94
|
+
function resolveScope(argv) {
|
|
95
|
+
return resolveFlagValue(argv, "--scope");
|
|
96
|
+
}
|
|
44
97
|
function resolveValidatedScope(argv) {
|
|
45
98
|
const raw = resolveScope(argv);
|
|
46
99
|
if (raw === null) return null;
|
|
@@ -52,22 +105,53 @@ function resolveValidatedScope(argv) {
|
|
|
52
105
|
}
|
|
53
106
|
return raw;
|
|
54
107
|
}
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
108
|
+
function resolveContextPath(argv) {
|
|
109
|
+
return resolveFlagValue(argv, "--context-path");
|
|
110
|
+
}
|
|
111
|
+
function resolvePrefixHash(argv) {
|
|
112
|
+
return resolveFlagValue(argv, "--prefix-hash");
|
|
113
|
+
}
|
|
114
|
+
function resolvePrefixFile(argv) {
|
|
115
|
+
return resolveFlagValue(argv, "--prefix-file");
|
|
116
|
+
}
|
|
117
|
+
// Round = the current head SHA, so a retry on a new head gets a fresh key while
|
|
118
|
+
// a same-head re-entry collides and fails closed. `git rev-parse HEAD` yields the
|
|
119
|
+
// same full SHA on every invocation for a given head, so the key is deterministic
|
|
120
|
+
// with no user input to spell it inconsistently. Returns null when git is
|
|
121
|
+
// unavailable (falls back to the legacy scope-only key).
|
|
122
|
+
function resolveHeadRound(cwd = process.cwd()) {
|
|
123
|
+
try {
|
|
124
|
+
const sha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
125
|
+
cwd,
|
|
126
|
+
encoding: "utf8",
|
|
127
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
128
|
+
}).trim();
|
|
129
|
+
return VALID_SCOPE_RE.test(sha) ? sha : null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null; // not a git repo / git unavailable
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function sentinelRelative(scope, round) {
|
|
135
|
+
const scopeSuffix = scope ? `-${scope}` : "";
|
|
136
|
+
const roundSuffix = round ? `-${round}` : "";
|
|
137
|
+
return path.join("tmp", `checkpoint-context-sentinel${scopeSuffix}${roundSuffix}.json`);
|
|
58
138
|
}
|
|
59
139
|
function legacySentinelRelative(scope) {
|
|
60
140
|
const suffix = scope ? `-${scope}` : "";
|
|
61
141
|
return path.join("tmp", `gate-review-context-sentinel${suffix}.json`);
|
|
62
142
|
}
|
|
63
|
-
async function checkSentinelExists(scope, cwd = process.cwd()) {
|
|
64
|
-
const sentinelPath = path.resolve(cwd, sentinelRelative(scope));
|
|
143
|
+
async function checkSentinelExists(scope, round, cwd = process.cwd()) {
|
|
144
|
+
const sentinelPath = path.resolve(cwd, sentinelRelative(scope, round));
|
|
65
145
|
try { await stat(sentinelPath); return { exists: true, path: sentinelPath, legacy: false }; } catch (err) {
|
|
66
146
|
if (err.code !== "ENOENT") throw err;
|
|
67
147
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
148
|
+
// Legacy names predate head-keyed rounds; only consult them for the
|
|
149
|
+
// scope-only key so a stale pre-round sentinel never blocks a new round.
|
|
150
|
+
if (!round) {
|
|
151
|
+
const legacyPath = path.resolve(cwd, legacySentinelRelative(scope));
|
|
152
|
+
try { await stat(legacyPath); return { exists: true, path: legacyPath, legacy: true }; } catch (err) {
|
|
153
|
+
if (err.code !== "ENOENT") throw err;
|
|
154
|
+
}
|
|
71
155
|
}
|
|
72
156
|
return { exists: false, path: sentinelPath, legacy: false };
|
|
73
157
|
}
|
|
@@ -78,27 +162,135 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
78
162
|
}
|
|
79
163
|
const scope = resolveValidatedScope(argv);
|
|
80
164
|
if (scope === undefined) return 2;
|
|
81
|
-
const
|
|
165
|
+
const contextPathArg = resolveContextPath(argv);
|
|
166
|
+
if (contextPathArg === "") {
|
|
167
|
+
process.stderr.write(`${formatCliError(
|
|
168
|
+
parseError("Invalid --context-path value: must be non-empty.")
|
|
169
|
+
)}\n`);
|
|
170
|
+
return 2;
|
|
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
|
+
}
|
|
198
|
+
const jqArg = resolveFlagValue(argv, "--jq");
|
|
199
|
+
if (jqArg === "") {
|
|
200
|
+
process.stderr.write(`${formatCliError(
|
|
201
|
+
parseError("Invalid --jq value: must be non-empty.")
|
|
202
|
+
)}\n`);
|
|
203
|
+
return 2;
|
|
204
|
+
}
|
|
205
|
+
const jq = jqArg === null ? undefined : jqArg;
|
|
206
|
+
const silent = argv.includes("--silent") || argv.includes("-s");
|
|
207
|
+
// Every branch below reports ok:true (the tool ran successfully); the
|
|
208
|
+
// fresh/contaminated verdict maps to the exit code via `ok` here so the
|
|
209
|
+
// shared --jq/--silent contract composes with the existing 0=fresh/1=refuse
|
|
210
|
+
// signal instead of always reading ok:true as success.
|
|
211
|
+
const finish = (payload, freshLike) => emitResult(payload, { jq, silent, ok: freshLike });
|
|
212
|
+
const round = resolveHeadRound();
|
|
213
|
+
if (contextPathArg !== null) {
|
|
214
|
+
const cwd = process.cwd();
|
|
215
|
+
const resolvedContextPath = path.resolve(cwd, contextPathArg);
|
|
216
|
+
// The guard proves the reviewer is in the working tree where the gitignored
|
|
217
|
+
// tmp/gate-context bundle was written. An absolute or ..-escaping path could
|
|
218
|
+
// stat the real bundle in ANOTHER (stale/isolated) worktree and pass — so a
|
|
219
|
+
// path resolving outside cwd fails closed, defeating that bypass.
|
|
220
|
+
const withinCwd =
|
|
221
|
+
resolvedContextPath === cwd || resolvedContextPath.startsWith(cwd + path.sep);
|
|
222
|
+
if (!withinCwd) {
|
|
223
|
+
return finish({
|
|
224
|
+
ok: true,
|
|
225
|
+
fresh: false,
|
|
226
|
+
sentinelCreated: false,
|
|
227
|
+
round: round ?? null,
|
|
228
|
+
gateContextPath: contextPathArg,
|
|
229
|
+
gateContextPresent: false,
|
|
230
|
+
reason: `Seeded gate-context artifact path "${contextPathArg}" resolves outside the reviewer's working directory — refusing. --context-path must be a cwd-relative path to the worktree-local bundle; an absolute or ..-escaping path could point at another (stale/isolated) worktree's bundle and defeat the worktree-locality guard.`,
|
|
231
|
+
}, false);
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
await stat(resolvedContextPath);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
if (err.code !== "ENOENT") throw err;
|
|
237
|
+
return finish({
|
|
238
|
+
ok: true,
|
|
239
|
+
fresh: false,
|
|
240
|
+
sentinelCreated: false,
|
|
241
|
+
round: round ?? null,
|
|
242
|
+
gateContextPath: contextPathArg,
|
|
243
|
+
gateContextPresent: false,
|
|
244
|
+
reason: `Seeded gate-context artifact missing at "${contextPathArg}" — refusing to review without the build-once neutral context bundle. Per-angle gate reviewers must run in the PR's actual worktree/head (never an isolated worktree checked out from stale main), which is where the context-builder preamble wrote this gitignored artifact.`,
|
|
245
|
+
}, false);
|
|
246
|
+
}
|
|
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
|
+
}
|
|
271
|
+
const sentinelPath = path.resolve(process.cwd(), sentinelRelative(scope, round));
|
|
82
272
|
try {
|
|
83
273
|
await mkdir(path.dirname(sentinelPath), { recursive: true });
|
|
84
274
|
} catch (err) {
|
|
85
275
|
process.stderr.write(`${formatCliError(err)}\n`);
|
|
86
276
|
return 2;
|
|
87
277
|
}
|
|
88
|
-
const existing = await checkSentinelExists(scope);
|
|
278
|
+
const existing = await checkSentinelExists(scope, round);
|
|
89
279
|
if (existing.exists) {
|
|
90
|
-
|
|
280
|
+
return finish({
|
|
91
281
|
ok: true,
|
|
92
282
|
fresh: false,
|
|
93
283
|
sentinelCreated: false,
|
|
284
|
+
round: round ?? null,
|
|
94
285
|
reason: `Checkpoint context sentinel already exists${existing.legacy ? " (legacy name)" : ""} — inherited session context detected. Restart the subagent with fresh context (subagent({context:\"fresh\"})).`,
|
|
95
|
-
}
|
|
96
|
-
return 1;
|
|
286
|
+
}, false);
|
|
97
287
|
}
|
|
98
288
|
const sentinel = {
|
|
99
289
|
createdAt: new Date().toISOString(),
|
|
100
290
|
pid: process.pid,
|
|
101
291
|
...(scope ? { scope } : {}),
|
|
292
|
+
...(round ? { round } : {}),
|
|
293
|
+
...(prefixHash ? { prefixHash } : {}),
|
|
102
294
|
};
|
|
103
295
|
try {
|
|
104
296
|
await writeFile(sentinelPath, JSON.stringify(sentinel, null, 2) + "\n", {
|
|
@@ -107,23 +299,25 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
107
299
|
});
|
|
108
300
|
} catch (err) {
|
|
109
301
|
if (err.code === "EEXIST") {
|
|
110
|
-
|
|
302
|
+
return finish({
|
|
111
303
|
ok: true,
|
|
112
304
|
fresh: false,
|
|
113
305
|
sentinelCreated: false,
|
|
306
|
+
round: round ?? null,
|
|
114
307
|
reason: "Checkpoint context sentinel already exists (detected on atomic create) — inherited session context detected. Restart the subagent with fresh context (subagent({context:\"fresh\"})).",
|
|
115
|
-
}
|
|
116
|
-
return 1;
|
|
308
|
+
}, false);
|
|
117
309
|
}
|
|
118
310
|
process.stderr.write(`${formatCliError(err)}\n`);
|
|
119
311
|
return 2;
|
|
120
312
|
}
|
|
121
|
-
|
|
313
|
+
return finish({
|
|
122
314
|
ok: true,
|
|
123
315
|
fresh: true,
|
|
124
316
|
sentinelCreated: true,
|
|
125
|
-
|
|
126
|
-
|
|
317
|
+
round: round ?? null,
|
|
318
|
+
...(contextPathArg !== null ? { gateContextPath: contextPathArg, gateContextPresent: true } : {}),
|
|
319
|
+
...(prefixHash ? { prefixHash } : {}),
|
|
320
|
+
}, true);
|
|
127
321
|
}
|
|
128
322
|
if (isDirectCliRun(import.meta.url)) {
|
|
129
323
|
try {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
3
|
+
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
4
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
7
|
+
|
|
8
|
+
// Default PR facts a follow-up run reads: the fields loop info surfaces plus the
|
|
9
|
+
// head SHA. Callers needing a different set pass --json <fields> (a comma list,
|
|
10
|
+
// same vocabulary as `gh pr view --json`).
|
|
11
|
+
const DEFAULT_FIELDS =
|
|
12
|
+
"number,title,body,state,isDraft,headRefName,baseRefName,headRefOid,author,mergedAt,mergeable,mergeStateStatus,url";
|
|
13
|
+
|
|
14
|
+
const USAGE = `Usage: view-pr.mjs --repo <owner/name> --pr <number> [--json <fields>]
|
|
15
|
+
Read PR facts (branch/state/mergeStateStatus/head SHA/etc.). Thin wrapper over
|
|
16
|
+
\`gh pr view --json …\` — use this instead of an agent-level raw \`gh pr view\` so the
|
|
17
|
+
loop's internal-tooling record stays clean (siblings: list-issues.mjs,
|
|
18
|
+
comment-issue.mjs, fetch-ci-logs.mjs; #1057). For composite loop routing/CI facts
|
|
19
|
+
prefer \`dev-loops loop info --pr\`; this is the thin field-read counterpart.
|
|
20
|
+
Required:
|
|
21
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
22
|
+
--pr <number> Pull request number
|
|
23
|
+
Optional:
|
|
24
|
+
--json <fields> Comma-separated gh pr view field list
|
|
25
|
+
(default: ${DEFAULT_FIELDS})
|
|
26
|
+
Output (stdout, JSON):
|
|
27
|
+
{ "ok": true, "pr": { <requested fields> } }
|
|
28
|
+
Error output (stderr, JSON):
|
|
29
|
+
{ "ok": false, "error": "...", "usage"?: "..." }
|
|
30
|
+
${JQ_OUTPUT_USAGE}
|
|
31
|
+
Exit codes:
|
|
32
|
+
0 Success
|
|
33
|
+
1 Argument error or gh failure
|
|
34
|
+
2 Invalid --jq filter`.trim();
|
|
35
|
+
const parseError = buildParseError(USAGE);
|
|
36
|
+
|
|
37
|
+
export function parseViewPrCliArgs(argv) {
|
|
38
|
+
const { tokens } = parseArgs({
|
|
39
|
+
args: [...argv],
|
|
40
|
+
options: {
|
|
41
|
+
help: { type: "boolean", short: "h" },
|
|
42
|
+
repo: { type: "string" },
|
|
43
|
+
pr: { type: "string" },
|
|
44
|
+
json: { type: "string" },
|
|
45
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
46
|
+
},
|
|
47
|
+
allowPositionals: true,
|
|
48
|
+
strict: false,
|
|
49
|
+
tokens: true,
|
|
50
|
+
});
|
|
51
|
+
const options = {
|
|
52
|
+
help: false,
|
|
53
|
+
repo: undefined,
|
|
54
|
+
pr: undefined,
|
|
55
|
+
fields: DEFAULT_FIELDS,
|
|
56
|
+
jq: undefined,
|
|
57
|
+
silent: false,
|
|
58
|
+
};
|
|
59
|
+
for (const token of tokens) {
|
|
60
|
+
if (token.kind === "positional") {
|
|
61
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
62
|
+
}
|
|
63
|
+
if (token.kind !== "option") {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (token.name === "help") {
|
|
67
|
+
options.help = true;
|
|
68
|
+
return options;
|
|
69
|
+
}
|
|
70
|
+
if (token.name === "repo") {
|
|
71
|
+
options.repo = requireTokenValue(token, parseError).trim();
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (token.name === "pr") {
|
|
75
|
+
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (token.name === "json") {
|
|
79
|
+
const fields = requireTokenValue(token, parseError)
|
|
80
|
+
.split(",")
|
|
81
|
+
.map((f) => f.trim())
|
|
82
|
+
.filter((f) => f.length > 0);
|
|
83
|
+
if (fields.length === 0) {
|
|
84
|
+
throw parseError("--json must list at least one field");
|
|
85
|
+
}
|
|
86
|
+
if (fields.some((f) => !/^[A-Za-z][A-Za-z0-9]*$/.test(f))) {
|
|
87
|
+
throw parseError("--json fields must be gh pr view field names (letters/digits)");
|
|
88
|
+
}
|
|
89
|
+
options.fields = fields.join(",");
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
93
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
94
|
+
}
|
|
95
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
96
|
+
throw parseError("Viewing a PR requires both --repo <owner/name> and --pr <number>");
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
parseRepoSlug(options.repo);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
102
|
+
}
|
|
103
|
+
return options;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function viewPr(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
|
|
107
|
+
const result = await run(
|
|
108
|
+
ghCommand,
|
|
109
|
+
["pr", "view", String(options.pr), "--repo", options.repo, "--json", options.fields],
|
|
110
|
+
env,
|
|
111
|
+
);
|
|
112
|
+
if (result.code !== 0) {
|
|
113
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
114
|
+
throw new Error(`gh pr view failed: ${detail}`);
|
|
115
|
+
}
|
|
116
|
+
const pr = parseJsonText(result.stdout, { label: "gh pr view" });
|
|
117
|
+
if (pr === null || typeof pr !== "object" || Array.isArray(pr)) {
|
|
118
|
+
throw new Error("gh pr view did not return a JSON object");
|
|
119
|
+
}
|
|
120
|
+
return { ok: true, pr };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function runCli(
|
|
124
|
+
argv = process.argv.slice(2),
|
|
125
|
+
{ stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
|
|
126
|
+
) {
|
|
127
|
+
let options;
|
|
128
|
+
try {
|
|
129
|
+
options = parseViewPrCliArgs(argv);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
stderr.write(`${formatCliError(error)}\n`);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
if (options.help) {
|
|
135
|
+
stdout.write(`${USAGE}\n`);
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
let result;
|
|
139
|
+
try {
|
|
140
|
+
result = await viewPr(options, { env, ghCommand, run });
|
|
141
|
+
} catch (error) {
|
|
142
|
+
stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
143
|
+
return 1;
|
|
144
|
+
}
|
|
145
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
149
|
+
runCli().then((code) => { process.exitCode = code; });
|
|
150
|
+
}
|
|
@@ -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
|
+
}
|