dev-loops 0.1.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/.pi/dev-loop/defaults.yaml +477 -0
- package/AGENTS.md +25 -0
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/agents/dev-loop.agent.md +82 -0
- package/agents/developer.agent.md +37 -0
- package/agents/docs.agent.md +33 -0
- package/agents/fixer.agent.md +53 -0
- package/agents/quality.agent.md +28 -0
- package/agents/refiner.agent.md +87 -0
- package/agents/review.agent.md +64 -0
- package/cli/index.mjs +424 -0
- package/extension/README.md +233 -0
- package/extension/checks.ts +94 -0
- package/extension/index.ts +131 -0
- package/extension/post-merge-update.ts +512 -0
- package/extension/presentation.ts +107 -0
- package/lib/dev-loops-core.mjs +284 -0
- package/package.json +103 -0
- package/scripts/README.md +1007 -0
- package/scripts/_cli-primitives.mjs +10 -0
- package/scripts/_core-helpers.mjs +30 -0
- package/scripts/docs/validate-links.mjs +567 -0
- package/scripts/docs/validate-no-duplicate-rules.mjs +250 -0
- package/scripts/github/_review-thread-mutations.mjs +214 -0
- package/scripts/github/capture-review-threads.mjs +180 -0
- package/scripts/github/create-draft-pr.mjs +108 -0
- package/scripts/github/detect-checkpoint-evidence.mjs +393 -0
- package/scripts/github/detect-linked-issue-pr.mjs +331 -0
- package/scripts/github/manage-sub-issues.mjs +394 -0
- package/scripts/github/probe-copilot-review.mjs +323 -0
- package/scripts/github/ready-for-review.mjs +93 -0
- package/scripts/github/reconcile-draft-gate.mjs +328 -0
- package/scripts/github/reply-resolve-review-thread.mjs +42 -0
- package/scripts/github/reply-resolve-review-threads.mjs +329 -0
- package/scripts/github/request-copilot-review.mjs +551 -0
- package/scripts/github/resolve-tracker-local-spec.mjs +205 -0
- package/scripts/github/stage-reviewer-draft.mjs +191 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +694 -0
- package/scripts/github/verify-fresh-review-context.mjs +125 -0
- package/scripts/github/write-gate-findings-log.mjs +212 -0
- package/scripts/loop/_checkpoint-io.mjs +55 -0
- package/scripts/loop/_checkpoint-paths.mjs +28 -0
- package/scripts/loop/_handoff-contract.mjs +230 -0
- package/scripts/loop/_inspect-run-viewer-adapter.mjs +345 -0
- package/scripts/loop/_loop-evidence.mjs +32 -0
- package/scripts/loop/_pr-runner-coordination.mjs +611 -0
- package/scripts/loop/_stale-runner-detection.mjs +145 -0
- package/scripts/loop/_steering-state-file.mjs +134 -0
- package/scripts/loop/build-handoff-envelope.mjs +181 -0
- package/scripts/loop/checkpoint-contract.mjs +49 -0
- package/scripts/loop/conductor-monitor.mjs +1850 -0
- package/scripts/loop/conductor.mjs +214 -0
- package/scripts/loop/copilot-pr-handoff.mjs +493 -0
- package/scripts/loop/debt-remediate.mjs +304 -0
- package/scripts/loop/detect-change-scope.mjs +102 -0
- package/scripts/loop/detect-copilot-loop-state.mjs +454 -0
- package/scripts/loop/detect-copilot-session-activity.mjs +186 -0
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +318 -0
- package/scripts/loop/detect-internal-only-pr.mjs +270 -0
- package/scripts/loop/detect-issue-refinement-artifact.mjs +163 -0
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +509 -0
- package/scripts/loop/detect-reviewer-loop-state.mjs +231 -0
- package/scripts/loop/detect-stale-runner.mjs +250 -0
- package/scripts/loop/detect-tracker-first-loop-state.mjs +76 -0
- package/scripts/loop/detect-tracker-pr-state.mjs +102 -0
- package/scripts/loop/info.mjs +267 -0
- package/scripts/loop/inspect-run-viewer/cli.mjs +117 -0
- package/scripts/loop/inspect-run-viewer/constants.mjs +80 -0
- package/scripts/loop/inspect-run-viewer/graph.mjs +757 -0
- package/scripts/loop/inspect-run-viewer/handoff-envelope-renderer.mjs +398 -0
- package/scripts/loop/inspect-run-viewer/inbox.mjs +308 -0
- package/scripts/loop/inspect-run-viewer/managed-instance.mjs +750 -0
- package/scripts/loop/inspect-run-viewer/rendering.mjs +411 -0
- package/scripts/loop/inspect-run-viewer/server.mjs +638 -0
- package/scripts/loop/inspect-run-viewer/shared.mjs +103 -0
- package/scripts/loop/inspect-run-viewer/status.mjs +715 -0
- package/scripts/loop/inspect-run-viewer-ci-changes.mjs +77 -0
- package/scripts/loop/inspect-run-viewer.mjs +82 -0
- package/scripts/loop/inspect-run.mjs +382 -0
- package/scripts/loop/outer-loop.mjs +419 -0
- package/scripts/loop/pr-runner-coordination.mjs +143 -0
- package/scripts/loop/pre-commit-branch-guard.mjs +68 -0
- package/scripts/loop/pre-flight-gate.mjs +236 -0
- package/scripts/loop/pre-pr-ready-gate.mjs +183 -0
- package/scripts/loop/pre-push-main-guard.mjs +103 -0
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +32 -0
- package/scripts/loop/print-gates.mjs +42 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +533 -0
- package/scripts/loop/run-conductor-cycle.mjs +322 -0
- package/scripts/loop/run-queue.mjs +124 -0
- package/scripts/loop/run-refinement-audit.mjs +513 -0
- package/scripts/loop/run-watch-cycle.mjs +358 -0
- package/scripts/loop/steer-loop.mjs +841 -0
- package/scripts/loop/ui-designer-review-contract.mjs +76 -0
- package/scripts/loop/watch-initial-copilot-pr.mjs +253 -0
- package/scripts/projects/add-queue-item.mjs +528 -0
- package/scripts/projects/ensure-queue-board.mjs +837 -0
- package/scripts/projects/list-queue-items.mjs +489 -0
- package/scripts/projects/move-queue-item.mjs +549 -0
- package/scripts/projects/reorder-queue-item.mjs +518 -0
- package/scripts/refine/_refine-helpers.mjs +258 -0
- package/scripts/refine/prose-linkage-detector.mjs +92 -0
- package/scripts/refine/refinement-completeness-checker.mjs +88 -0
- package/scripts/refine/scope-boundary-cross-checker.mjs +163 -0
- package/scripts/refine/tree-integrity-validator.mjs +211 -0
- package/scripts/refine/verify.mjs +178 -0
- package/scripts/repo-wiki-local.mjs +156 -0
- package/scripts/repo-wiki.mjs +119 -0
- package/skills/copilot-pr-followup/SKILL.md +380 -0
- package/skills/dev-loop/SKILL.md +141 -0
- package/skills/dev-loop/scripts/dev-mode-context.mjs +152 -0
- package/skills/dev-loop/scripts/dev-mode-context.test.mjs +80 -0
- package/skills/dev-loop/scripts/init-phase.mjs +71 -0
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +25 -0
- package/skills/dev-loop/scripts/phase-files.mjs +29 -0
- package/skills/dev-loop/scripts/post-gate-verdict-fallback.mjs +480 -0
- package/skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs +732 -0
- package/skills/dev-loop/scripts/render-template.mjs +82 -0
- package/skills/dev-loop/scripts/render-template.test.mjs +63 -0
- package/skills/dev-loop/templates/bootstrap-agents.md +26 -0
- package/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
- package/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
- package/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
- package/skills/dev-loop/templates/dev-mode-review.md +17 -0
- package/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
- package/skills/dev-loop/templates/merged-phase-plan.md +19 -0
- package/skills/dev-loop/templates/phase-doc.md +27 -0
- package/skills/dev-loop/templates/phase-summary.md +13 -0
- package/skills/dev-loop/templates/phase-variant.md +15 -0
- package/skills/dev-loop/templates/retrospective.md +11 -0
- package/skills/dev-loop/templates/review.md +32 -0
- package/skills/dev-loop/templates/ui-vision-review.md +55 -0
- package/skills/docs/acceptance-criteria-verification.md +21 -0
- package/skills/docs/anti-patterns.md +21 -0
- package/skills/docs/artifact-authority-contract.md +119 -0
- package/skills/docs/confirmation-rules.md +28 -0
- package/skills/docs/copilot-ci-status-contract.md +52 -0
- package/skills/docs/copilot-loop-operations.md +233 -0
- package/skills/docs/debt-remediation-contract.md +107 -0
- package/skills/docs/entrypoint-strategies.md +115 -0
- package/skills/docs/epic-tree-refinement-procedure.md +234 -0
- package/skills/docs/issue-intake-procedure.md +235 -0
- package/skills/docs/main-agent-contract.md +72 -0
- package/skills/docs/merge-preconditions.md +29 -0
- package/skills/docs/pr-lifecycle-contract.md +209 -0
- package/skills/docs/public-dev-loop-contract.md +497 -0
- package/skills/docs/retrospective-checkpoint-contract.md +159 -0
- package/skills/docs/stop-conditions.md +29 -0
- package/skills/docs/structural-quality.md +42 -0
- package/skills/docs/tracker-first-loop-state.md +281 -0
- package/skills/docs/validation-policy.md +27 -0
- package/skills/docs/workflow-handoff-contract.md +135 -0
- package/skills/final-approval/SKILL.md +19 -0
- package/skills/local-implementation/SKILL.md +640 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
4
|
+
import { requireOptionValue } from "../_cli-primitives.mjs";
|
|
5
|
+
import { createPiAdapter } from "@dev-loops/core/harness";
|
|
6
|
+
import {
|
|
7
|
+
isUnderWorktreePath,
|
|
8
|
+
parseMainWorktreePath,
|
|
9
|
+
isMainCheckout,
|
|
10
|
+
parseAllWorktreePaths,
|
|
11
|
+
isListedWorktree,
|
|
12
|
+
detectSubagentAvailability,
|
|
13
|
+
} from "@dev-loops/core/loop/worktree-guard";
|
|
14
|
+
const PI_PREFLIGHT_BYPASS_VAR = "PI_PREFLIGHT_BYPASS";
|
|
15
|
+
const USAGE = `Usage:
|
|
16
|
+
pre-flight-gate.mjs [--expected-branch <name>] [--check-subagents]
|
|
17
|
+
Gate local implementation mutations before planning or editing.
|
|
18
|
+
Required environment:
|
|
19
|
+
(none)
|
|
20
|
+
Optional:
|
|
21
|
+
--expected-branch <name> Expected current branch (for branch identity check).
|
|
22
|
+
--check-subagents Check subagent availability (advisory; fails-open).
|
|
23
|
+
Success output (stdout, JSON):
|
|
24
|
+
{ "ok": true, "checks": { "worktree": true, "branch": "matched",
|
|
25
|
+
"subagents": "available" } }
|
|
26
|
+
Violation output (stderr, JSON, exit 1):
|
|
27
|
+
{ "ok": false, "error": "<error_code>", "checks": { ... },
|
|
28
|
+
"guidance": "<actionable instruction for the agent>" }
|
|
29
|
+
Bypass:
|
|
30
|
+
PI_PREFLIGHT_BYPASS=1 Skip all checks (for development/testing only).`.trim();
|
|
31
|
+
const parseError = buildParseError(USAGE);
|
|
32
|
+
export function parsePreFlightGateCliArgs(argv) {
|
|
33
|
+
const args = [...argv];
|
|
34
|
+
const options = {
|
|
35
|
+
help: false,
|
|
36
|
+
expectedBranch: undefined,
|
|
37
|
+
checkSubagents: false,
|
|
38
|
+
};
|
|
39
|
+
while (args.length > 0) {
|
|
40
|
+
const token = args.shift();
|
|
41
|
+
if (token === "--help" || token === "-h") {
|
|
42
|
+
options.help = true;
|
|
43
|
+
return options;
|
|
44
|
+
}
|
|
45
|
+
if (token === "--expected-branch") {
|
|
46
|
+
options.expectedBranch = requireOptionValue(args, "--expected-branch", parseError, { flagPattern: /^-/u });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (token === "--check-subagents") {
|
|
50
|
+
options.checkSubagents = true;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
54
|
+
}
|
|
55
|
+
return options;
|
|
56
|
+
}
|
|
57
|
+
function checkWorktreeIsolation({ cwd, env, gitCommand = "git" }) {
|
|
58
|
+
let worktreeListOutput;
|
|
59
|
+
try {
|
|
60
|
+
worktreeListOutput = execFileSync(gitCommand, ["worktree", "list"], {
|
|
61
|
+
cwd,
|
|
62
|
+
env,
|
|
63
|
+
encoding: "utf8",
|
|
64
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
65
|
+
});
|
|
66
|
+
} catch {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
error: "worktree_list_failed",
|
|
70
|
+
guidance: "Could not run `git worktree list`. Verify the repository is a valid git working directory.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const mainWorktreePath = parseMainWorktreePath(worktreeListOutput);
|
|
74
|
+
if (!isUnderWorktreePath(cwd)) {
|
|
75
|
+
if (mainWorktreePath !== null && isMainCheckout(cwd, mainWorktreePath)) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: "main_checkout_detected",
|
|
79
|
+
guidance:
|
|
80
|
+
`Current directory appears to be the main git checkout (${mainWorktreePath}).\n` +
|
|
81
|
+
"Local implementation requires worktree isolation. Create a worktree:\n" +
|
|
82
|
+
" git worktree add -b <branch> tmp/worktrees/<slug>/ origin/main\n" +
|
|
83
|
+
"Then re-run from the worktree directory.",
|
|
84
|
+
mainWorktreePath,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
error: "not_in_worktree",
|
|
90
|
+
guidance:
|
|
91
|
+
"Local implementation requires worktree isolation. Create a worktree:\n" +
|
|
92
|
+
" git worktree add -b <branch> tmp/worktrees/<slug>/ origin/main\n" +
|
|
93
|
+
"Then re-run from the worktree directory.",
|
|
94
|
+
mainWorktreePath: mainWorktreePath ?? undefined,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const allPaths = parseAllWorktreePaths(worktreeListOutput);
|
|
98
|
+
if (!isListedWorktree(cwd, allPaths)) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: "not_in_worktree",
|
|
102
|
+
guidance:
|
|
103
|
+
"Current directory is under tmp/worktrees/ but is not a real git worktree.\n" +
|
|
104
|
+
"Create a worktree with:\n" +
|
|
105
|
+
" git worktree add -b <branch> tmp/worktrees/<slug>/ origin/main\n" +
|
|
106
|
+
"Then re-run from the worktree directory.",
|
|
107
|
+
mainWorktreePath: mainWorktreePath ?? undefined,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, mainWorktreePath: mainWorktreePath ?? undefined };
|
|
111
|
+
}
|
|
112
|
+
function checkBranchIdentity({ cwd, env, expectedBranch, gitCommand = "git" }) {
|
|
113
|
+
if (!expectedBranch) {
|
|
114
|
+
return { ok: true, status: "skipped" };
|
|
115
|
+
}
|
|
116
|
+
let currentBranch;
|
|
117
|
+
try {
|
|
118
|
+
currentBranch = execFileSync(gitCommand, ["branch", "--show-current"], {
|
|
119
|
+
cwd,
|
|
120
|
+
env,
|
|
121
|
+
encoding: "utf8",
|
|
122
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
123
|
+
}).trim();
|
|
124
|
+
} catch {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
status: "error",
|
|
128
|
+
error: "branch_check_failed",
|
|
129
|
+
guidance: "Could not determine current branch. Verify the repository is a valid git working directory.",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (currentBranch !== expectedBranch) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
status: "mismatch",
|
|
136
|
+
error: "branch_mismatch",
|
|
137
|
+
guidance: `Expected branch "${expectedBranch}" but current branch is "${currentBranch}". Switch to the working branch and re-run.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, status: "matched", branch: currentBranch };
|
|
141
|
+
}
|
|
142
|
+
function checkSubagentAvailability({ env, checkSubagents }) {
|
|
143
|
+
if (!checkSubagents) {
|
|
144
|
+
return { ok: true, status: "skipped" };
|
|
145
|
+
}
|
|
146
|
+
const available = detectSubagentAvailability({ env });
|
|
147
|
+
return { ok: true, status: available ? "available" : "unavailable" };
|
|
148
|
+
}
|
|
149
|
+
export async function runCli(
|
|
150
|
+
argv = process.argv.slice(2),
|
|
151
|
+
{
|
|
152
|
+
stdout = process.stdout,
|
|
153
|
+
stderr = process.stderr,
|
|
154
|
+
adapter = createPiAdapter(),
|
|
155
|
+
cwd,
|
|
156
|
+
env,
|
|
157
|
+
gitCommand = "git",
|
|
158
|
+
} = {},
|
|
159
|
+
) {
|
|
160
|
+
const effectiveCwd = cwd ?? adapter.getCwd();
|
|
161
|
+
const effectiveEnv = env ?? adapter.getEnv();
|
|
162
|
+
const options = parsePreFlightGateCliArgs(argv);
|
|
163
|
+
if (options.help) {
|
|
164
|
+
stdout.write(`${USAGE}\n`);
|
|
165
|
+
return { ok: true, help: true };
|
|
166
|
+
}
|
|
167
|
+
if ((effectiveEnv[PI_PREFLIGHT_BYPASS_VAR] ?? "").trim() === "1") {
|
|
168
|
+
const payload = {
|
|
169
|
+
ok: true,
|
|
170
|
+
checks: { worktree: true, branch: "skipped", subagents: "skipped" },
|
|
171
|
+
summary: "pre-flight gate bypassed via PI_PREFLIGHT_BYPASS=1",
|
|
172
|
+
};
|
|
173
|
+
stdout.write(`${JSON.stringify(payload)}\n`);
|
|
174
|
+
return payload;
|
|
175
|
+
}
|
|
176
|
+
const checks = { worktree: false, branch: "skipped", subagents: "skipped" };
|
|
177
|
+
const errors = [];
|
|
178
|
+
const worktreeResult = checkWorktreeIsolation({ cwd: effectiveCwd, env: effectiveEnv, gitCommand });
|
|
179
|
+
checks.worktree = worktreeResult.ok;
|
|
180
|
+
if (!worktreeResult.ok) {
|
|
181
|
+
errors.push({
|
|
182
|
+
check: "worktree",
|
|
183
|
+
error: worktreeResult.error,
|
|
184
|
+
guidance: worktreeResult.guidance,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const branchResult = checkBranchIdentity({
|
|
188
|
+
cwd: effectiveCwd,
|
|
189
|
+
env: effectiveEnv,
|
|
190
|
+
expectedBranch: options.expectedBranch,
|
|
191
|
+
gitCommand,
|
|
192
|
+
});
|
|
193
|
+
checks.branch = branchResult.status;
|
|
194
|
+
if (!branchResult.ok) {
|
|
195
|
+
errors.push({
|
|
196
|
+
check: "branch",
|
|
197
|
+
error: branchResult.error,
|
|
198
|
+
guidance: branchResult.guidance,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const subagentResult = checkSubagentAvailability({
|
|
202
|
+
env: effectiveEnv,
|
|
203
|
+
checkSubagents: options.checkSubagents,
|
|
204
|
+
});
|
|
205
|
+
checks.subagents = subagentResult.status;
|
|
206
|
+
if (errors.length > 0) {
|
|
207
|
+
const payload = {
|
|
208
|
+
ok: false,
|
|
209
|
+
error: errors[0].error,
|
|
210
|
+
checks,
|
|
211
|
+
guidance: errors.map((e) => e.guidance).join("\n\n"),
|
|
212
|
+
errors,
|
|
213
|
+
};
|
|
214
|
+
stderr.write(`${JSON.stringify(payload)}\n`);
|
|
215
|
+
return payload;
|
|
216
|
+
}
|
|
217
|
+
const payload = {
|
|
218
|
+
ok: true,
|
|
219
|
+
checks,
|
|
220
|
+
summary: "all checks passed",
|
|
221
|
+
};
|
|
222
|
+
stdout.write(`${JSON.stringify(payload)}\n`);
|
|
223
|
+
return payload;
|
|
224
|
+
}
|
|
225
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
226
|
+
runCli()
|
|
227
|
+
.then((result) => {
|
|
228
|
+
if (result?.ok === false) {
|
|
229
|
+
process.exitCode = 1;
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
.catch((error) => {
|
|
233
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
});
|
|
236
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
buildParseError,
|
|
4
|
+
formatCliError,
|
|
5
|
+
isDirectCliRun,
|
|
6
|
+
parseJsonText,
|
|
7
|
+
summarizeGateReviewComments,
|
|
8
|
+
summarizeGateReviewCommentMarkers,
|
|
9
|
+
} from "../_core-helpers.mjs";
|
|
10
|
+
import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
|
|
11
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
12
|
+
|
|
13
|
+
const USAGE = `Usage:
|
|
14
|
+
pre-pr-ready-gate.mjs --repo <owner/name> --pr <number>
|
|
15
|
+
|
|
16
|
+
Gate guard for gh pr ready (draft → ready-for-review transition).
|
|
17
|
+
Blocks unless a visible clean draft_gate checkpoint verdict comment exists
|
|
18
|
+
for the PR's current head SHA.
|
|
19
|
+
|
|
20
|
+
Exit codes:
|
|
21
|
+
0 Draft gate evidence exists — ready transition is allowed
|
|
22
|
+
1 Draft gate evidence missing or insufficient — transition blocked
|
|
23
|
+
|
|
24
|
+
Output (stdout, JSON on success):
|
|
25
|
+
{
|
|
26
|
+
"ok": true,
|
|
27
|
+
"repo": "owner/repo",
|
|
28
|
+
"pr": 17,
|
|
29
|
+
"currentHeadSha": "abc1234",
|
|
30
|
+
"draftGateSatisfied": true,
|
|
31
|
+
"draftGate": { "visible": true, "headSha": "abc1234", "verdict": "clean", ... }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
Error output (stderr, JSON):
|
|
35
|
+
{ "ok": false, "error": "<reason>" }`.trim();
|
|
36
|
+
|
|
37
|
+
const parseError = buildParseError(USAGE);
|
|
38
|
+
const PR_VIEW_QUERY = `query($owner:String!, $name:String!, $number:Int!) { repository(owner:$owner, name:$name) { pullRequest(number:$number) { id, isDraft, headRefOid, state } } }`;
|
|
39
|
+
|
|
40
|
+
export function parsePrePrReadyGateCliArgs(argv) {
|
|
41
|
+
const args = [...argv];
|
|
42
|
+
const options = { help: false, repo: undefined, pr: undefined };
|
|
43
|
+
while (args.length > 0) {
|
|
44
|
+
const token = args.shift();
|
|
45
|
+
if (token === "--help" || token === "-h") { options.help = true; return options; }
|
|
46
|
+
if (token === "--repo") { options.repo = requireOptionValue(args, "--repo", parseError).trim(); continue; }
|
|
47
|
+
if (token === "--pr") { options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError); continue; }
|
|
48
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
49
|
+
}
|
|
50
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
51
|
+
throw parseError("pre-pr-ready-gate requires both --repo <owner/name> and --pr <number>");
|
|
52
|
+
}
|
|
53
|
+
try { parseRepoSlug(options.repo); } catch (error) {
|
|
54
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
55
|
+
}
|
|
56
|
+
return options;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function runGhJson(args, { env, ghCommand }) {
|
|
60
|
+
const result = await runChild(ghCommand, args, env);
|
|
61
|
+
if (result.code !== 0) {
|
|
62
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
63
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
64
|
+
}
|
|
65
|
+
return parseJsonText(result.stdout);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function fetchPrState({ repo, pr }, { env, ghCommand }) {
|
|
69
|
+
const [owner, name] = repo.split("/");
|
|
70
|
+
const r = await runGhJson(
|
|
71
|
+
["api", "graphql", "-f", `query=${PR_VIEW_QUERY}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${pr}`],
|
|
72
|
+
{ env, ghCommand },
|
|
73
|
+
);
|
|
74
|
+
const d = r?.data?.repository?.pullRequest;
|
|
75
|
+
if (!d) throw new Error(`Could not fetch PR #${pr}`);
|
|
76
|
+
return {
|
|
77
|
+
id: d.id,
|
|
78
|
+
isDraft: d.isDraft === true,
|
|
79
|
+
headRefOid: typeof d.headRefOid === "string" ? d.headRefOid.trim() : null,
|
|
80
|
+
state: typeof d.state === "string" ? d.state.trim() : null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function fetchGateEvidence({ repo, pr, headSha }, { env, ghCommand }) {
|
|
85
|
+
const r = await runChild(
|
|
86
|
+
ghCommand,
|
|
87
|
+
["api", "--paginate", "--slurp", `repos/${repo}/issues/${pr}/comments?per_page=100`],
|
|
88
|
+
env,
|
|
89
|
+
);
|
|
90
|
+
if (r.code !== 0) throw new Error(`Failed to fetch PR comments`);
|
|
91
|
+
const raw = parseJsonText(r.stdout);
|
|
92
|
+
const comments = Array.isArray(raw)
|
|
93
|
+
? (raw.every((e) => Array.isArray(e)) ? raw.flat() : raw)
|
|
94
|
+
: [];
|
|
95
|
+
const cs = summarizeGateReviewComments(comments);
|
|
96
|
+
const ms = summarizeGateReviewCommentMarkers(comments, { headSha });
|
|
97
|
+
|
|
98
|
+
const dg = cs.draft_gate ? { ...cs.draft_gate, visible: true } : { visible: false };
|
|
99
|
+
const dm = ms.draft_gate
|
|
100
|
+
? { ...ms.draft_gate, visible: true, contractComplete: ms.draft_gate.contractComplete === true }
|
|
101
|
+
: { visible: false, contractComplete: false };
|
|
102
|
+
|
|
103
|
+
// Marker match: current head SHA starts with the marker's recorded head SHA
|
|
104
|
+
const markerHeadMatch = dm.headSha && headSha && headSha.startsWith(dm.headSha);
|
|
105
|
+
const currentHeadClean = dm.visible && markerHeadMatch && dm.verdict === "clean" && dm.contractComplete;
|
|
106
|
+
|
|
107
|
+
// Legacy comment match (non-marker draft_gate comment)
|
|
108
|
+
const cleanEvidenceExists = dg.visible && dg.verdict === "clean" && typeof dg.headSha === "string";
|
|
109
|
+
const legacyHeadMatch = !currentHeadClean && dg.headSha && headSha && headSha.startsWith(dg.headSha) && dg.verdict === "clean";
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
draftGate: dg,
|
|
113
|
+
draftGateMarker: dm,
|
|
114
|
+
currentHeadClean,
|
|
115
|
+
cleanEvidenceExists,
|
|
116
|
+
effectiveHeadClean: currentHeadClean || legacyHeadMatch,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function prePrReadyGate(options, { env = process.env, ghCommand = "gh" } = {}) {
|
|
121
|
+
const prState = await fetchPrState({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
122
|
+
const headSha = prState.headRefOid;
|
|
123
|
+
if (!headSha) throw new Error(`Could not resolve PR head SHA`);
|
|
124
|
+
|
|
125
|
+
const gate = await fetchGateEvidence({ repo: options.repo, pr: options.pr, headSha }, { env, ghCommand });
|
|
126
|
+
|
|
127
|
+
// When the PR is no longer draft, a visible clean draft_gate comment that
|
|
128
|
+
// exists at all (one-time transition record) is sufficient — don't require
|
|
129
|
+
// head-SHA matching after draft has been left.
|
|
130
|
+
const gateSatisfied = prState.isDraft
|
|
131
|
+
? gate.effectiveHeadClean
|
|
132
|
+
: gate.cleanEvidenceExists;
|
|
133
|
+
|
|
134
|
+
if (!gateSatisfied) {
|
|
135
|
+
const shortSha = headSha.slice(0, 7);
|
|
136
|
+
const reason = gate.cleanEvidenceExists
|
|
137
|
+
? `PR #${options.pr} draft_gate evidence exists but does not match current head ${shortSha}. Re-run draft gate for the current head.`
|
|
138
|
+
: `No visible clean draft_gate checkpoint verdict comment found on PR #${options.pr} for head ${shortSha}. Run the draft gate review and post a clean verdict before marking ready for review.`;
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
error: reason,
|
|
142
|
+
repo: options.repo,
|
|
143
|
+
pr: options.pr,
|
|
144
|
+
currentHeadSha: headSha,
|
|
145
|
+
draftGateSatisfied: false,
|
|
146
|
+
draftGate: gate.draftGate,
|
|
147
|
+
draftGateMarker: gate.draftGateMarker,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
repo: options.repo,
|
|
154
|
+
pr: options.pr,
|
|
155
|
+
currentHeadSha: headSha,
|
|
156
|
+
draftGateSatisfied: true,
|
|
157
|
+
draftGate: gate.draftGate,
|
|
158
|
+
draftGateMarker: gate.draftGateMarker,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
163
|
+
const options = parsePrePrReadyGateCliArgs(argv);
|
|
164
|
+
if (options.help) {
|
|
165
|
+
process.stdout.write(`${USAGE}\n`);
|
|
166
|
+
return { ok: true, help: true };
|
|
167
|
+
}
|
|
168
|
+
const result = await prePrReadyGate(options, runtime);
|
|
169
|
+
if (!result.ok) {
|
|
170
|
+
process.stderr.write(`${JSON.stringify(result)}\n`);
|
|
171
|
+
process.exitCode = 1;
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
179
|
+
runCli().catch((error) => {
|
|
180
|
+
process.stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
|
|
181
|
+
process.exitCode = 1;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
4
|
+
|
|
5
|
+
const PI_PREPUSH_BYPASS_VAR = "PI_PREPUSH_BYPASS";
|
|
6
|
+
const BLOCKED_REFS = ["refs/heads/main"];
|
|
7
|
+
|
|
8
|
+
const USAGE = `Usage:
|
|
9
|
+
pre-push-main-guard.mjs
|
|
10
|
+
|
|
11
|
+
Reads pre-push hook input from stdin (Git pre-push hook protocol).
|
|
12
|
+
Blocks direct pushes to protected refs (by default: refs/heads/main).
|
|
13
|
+
|
|
14
|
+
Exit codes:
|
|
15
|
+
0 Push allowed (non-main ref, or bypassed)
|
|
16
|
+
1 Push blocked (target is a protected ref)
|
|
17
|
+
|
|
18
|
+
Bypass:
|
|
19
|
+
PI_PREPUSH_BYPASS=1 Skip all checks (for emergencies only).
|
|
20
|
+
Preferred: push a feature branch and open a PR.`.trim();
|
|
21
|
+
|
|
22
|
+
const parseError = buildParseError(USAGE);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse pre-push hook input lines.
|
|
26
|
+
*
|
|
27
|
+
* Git pre-push hook protocol: each line is four whitespace-delimited fields:
|
|
28
|
+
* <local ref> <local sha> <remote ref> <remote sha>
|
|
29
|
+
* The fourth field (remote sha) is all-zeros for new branches.
|
|
30
|
+
*
|
|
31
|
+
* This parser accepts lines with at least three fields (treating the
|
|
32
|
+
* optional fourth field as null when absent). Malformed non-empty
|
|
33
|
+
* lines with fewer than three fields are ignored silently — the guard
|
|
34
|
+
* prefers to fail-open on unparseable input rather than silently
|
|
35
|
+
* blocking unknown refs.
|
|
36
|
+
*/
|
|
37
|
+
async function readPushRefs(input) {
|
|
38
|
+
const refs = [];
|
|
39
|
+
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
40
|
+
for await (const line of rl) {
|
|
41
|
+
const trimmed = line.trim();
|
|
42
|
+
if (!trimmed) continue;
|
|
43
|
+
const parts = trimmed.split(/\s+/);
|
|
44
|
+
if (parts.length >= 3) {
|
|
45
|
+
refs.push({ localRef: parts[0], localSha: parts[1], remoteRef: parts[2], remoteSha: parts[3] || null });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return refs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check whether any push target is a blocked ref.
|
|
53
|
+
*/
|
|
54
|
+
function findBlockedRef(refs) {
|
|
55
|
+
for (const ref of refs) {
|
|
56
|
+
if (BLOCKED_REFS.includes(ref.remoteRef)) {
|
|
57
|
+
return ref.remoteRef;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parsePrePushGuardCliArgs(argv) {
|
|
64
|
+
const args = [...argv];
|
|
65
|
+
while (args.length > 0) {
|
|
66
|
+
const token = args.shift();
|
|
67
|
+
if (token === "--help" || token === "-h") return { help: true };
|
|
68
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
69
|
+
}
|
|
70
|
+
return { help: false };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function runCli(argv = process.argv.slice(2), { stdout = process.stdout, stderr = process.stderr, stdin = process.stdin, env = process.env } = {}) {
|
|
74
|
+
const options = parsePrePushGuardCliArgs(argv);
|
|
75
|
+
if (options.help) { stdout.write(`${USAGE}\n`); return { ok: true, help: true }; }
|
|
76
|
+
|
|
77
|
+
if (env[PI_PREPUSH_BYPASS_VAR] === "1") {
|
|
78
|
+
stdout.write(JSON.stringify({ ok: true, bypassed: true, reason: `${PI_PREPUSH_BYPASS_VAR}=1` }) + "\n");
|
|
79
|
+
return { ok: true, bypassed: true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const refs = await readPushRefs(stdin);
|
|
83
|
+
const blockedRef = findBlockedRef(refs);
|
|
84
|
+
|
|
85
|
+
if (blockedRef) {
|
|
86
|
+
const payload = {
|
|
87
|
+
ok: false,
|
|
88
|
+
error: "direct_push_to_main_blocked",
|
|
89
|
+
blockedRef,
|
|
90
|
+
message: "Direct pushes to main branch are blocked. Push a feature branch and open a pull request instead.",
|
|
91
|
+
};
|
|
92
|
+
stderr.write(`${JSON.stringify(payload)}\n`);
|
|
93
|
+
return payload;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const payload = { ok: true, blocked: false, refsChecked: refs.length };
|
|
97
|
+
stdout.write(`${JSON.stringify(payload)}\n`);
|
|
98
|
+
return payload;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
102
|
+
runCli().then((result) => { if (result?.ok === false) { process.exitCode = 1; } }).catch((error) => { process.stderr.write(`${formatCliError(error)}\n`); process.exitCode = 1; });
|
|
103
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
3
|
+
import { requireOptionValue, runCommand } from "../_cli-primitives.mjs";
|
|
4
|
+
|
|
5
|
+
const USAGE = `Usage: pre-write-remote-freshness-guard.mjs --branch <name>\nRefresh remote branch state before starting local file writes.`;
|
|
6
|
+
const parseError = buildParseError(USAGE);
|
|
7
|
+
|
|
8
|
+
export function parseRemoteFreshnessGuardCliArgs(argv) {
|
|
9
|
+
const args = [...argv], options = { help: false, branch: undefined };
|
|
10
|
+
while (args.length > 0) {
|
|
11
|
+
const token = args.shift();
|
|
12
|
+
if (token === "--help" || token === "-h") { options.help = true; return options; }
|
|
13
|
+
if (token === "--branch") { options.branch = requireOptionValue(args, "--branch", parseError, { flagPattern: /^-/u }); continue; }
|
|
14
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
15
|
+
}
|
|
16
|
+
if (options.branch === undefined) throw parseError("--branch <name> is required");
|
|
17
|
+
return options;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function runCli(argv = process.argv.slice(2), { stdout = process.stdout, stderr = process.stderr, cwd = process.cwd(), env = process.env, gitCommand = "git" } = {}) {
|
|
21
|
+
const options = parseRemoteFreshnessGuardCliArgs(argv);
|
|
22
|
+
if (options.help) { stdout.write(`${USAGE}\n`); return { ok: true, help: true }; }
|
|
23
|
+
await runCommand(gitCommand, ["fetch", "origin", options.branch], { cwd, env });
|
|
24
|
+
const { stdout: logOutput } = await runCommand(gitCommand, ["log", `HEAD..origin/${options.branch}`, "--oneline"], { cwd, env });
|
|
25
|
+
const newCommits = logOutput.split(/\r?\n/u).map(l => l.trim()).filter(l => l.length > 0);
|
|
26
|
+
if (newCommits.length === 0) { const p = { ok: true, status: "up_to_date" }; stdout.write(`${JSON.stringify(p)}\n`); return p; }
|
|
27
|
+
const p = { ok: false, error: "remote_ahead", newCommits }; stderr.write(`${JSON.stringify(p)}\n`); return p;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
31
|
+
runCli().then(r => { if (r?.ok === false) process.exitCode = 1; }).catch(e => { process.stderr.write(`${formatCliError(e)}\n`); process.exitCode = 1; });
|
|
32
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { loadDevLoopConfig } from "@dev-loops/core/config";
|
|
6
|
+
import { resolveGateConfig } from "@dev-loops/core/config";
|
|
7
|
+
import { resolveGateAngles, resolveReviewerRole } from "@dev-loops/core/config";
|
|
8
|
+
async function run({ stdout = process.stdout, repoRoot = process.cwd() } = {}) {
|
|
9
|
+
const { config } = await loadDevLoopConfig({ repoRoot });
|
|
10
|
+
const gates = [
|
|
11
|
+
{ name: "draft_gate", label: "draft gate", gate: "draft" },
|
|
12
|
+
{ name: "pre_approval_gate", label: "pre-approval gate", gate: "preApproval" },
|
|
13
|
+
];
|
|
14
|
+
for (const { label, gate } of gates) {
|
|
15
|
+
const gateConfig = resolveGateConfig(config, gate);
|
|
16
|
+
const angles = resolveGateAngles(config, gate);
|
|
17
|
+
const ciLabel = gate === "draft"
|
|
18
|
+
? String(gateConfig.requireCi)
|
|
19
|
+
: "true (always enforced)";
|
|
20
|
+
stdout.write(`${label}:\n`);
|
|
21
|
+
stdout.write(` requireCi: ${ciLabel}\n`);
|
|
22
|
+
if (!angles || angles.length === 0) {
|
|
23
|
+
stdout.write(" (no angles configured)\n\n");
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const maxLen = Math.max(...angles.map(a => a.length));
|
|
27
|
+
for (const angle of angles) {
|
|
28
|
+
const { prompt } = resolveReviewerRole(config, angle);
|
|
29
|
+
const displayPrompt = prompt ?? "(no prompt — add to config personas)";
|
|
30
|
+
stdout.write(` ${angle.padEnd(maxLen + 2)} ${displayPrompt}\n`);
|
|
31
|
+
}
|
|
32
|
+
stdout.write("\n");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
36
|
+
if (isDirectRun) {
|
|
37
|
+
run().catch(err => {
|
|
38
|
+
process.stderr.write(`${err.message}\n`);
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export { run };
|