dev-loops 0.5.0 → 0.7.1
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 +2 -1
- package/.claude/agents/review.md +2 -1
- package/.claude/commands/loop-auto.md +7 -0
- 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/loop-info.md +7 -0
- package/.claude/commands/loop-queue-status.md +24 -0
- package/.claude/commands/loop-start-spike.md +16 -0
- package/.claude/commands/loop-start.md +7 -0
- package/.claude/commands/loop-status.md +6 -0
- package/.claude/hooks/_bash-command-classify.mjs +333 -29
- package/.claude/hooks/_hook-decisions.mjs +138 -15
- package/.claude/hooks/_run-context.mjs +11 -4
- package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
- package/.claude/skills/copilot-pr-followup/SKILL.md +24 -12
- package/.claude/skills/dev-loop/SKILL.md +19 -10
- package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
- package/.claude/skills/docs/anti-patterns.md +2 -1
- package/.claude/skills/docs/artifact-authority-contract.md +22 -4
- package/.claude/skills/docs/copilot-loop-operations.md +1 -1
- package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
- package/.claude/skills/docs/issue-intake-procedure.md +1 -0
- 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 +17 -1
- package/.claude/skills/docs/plan-file-contract.md +1 -1
- package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
- package/.claude/skills/docs/release-runbook.md +45 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
- package/.claude/skills/docs/ui-e2e-scoping-step.md +134 -0
- package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
- package/.claude/skills/local-implementation/SKILL.md +26 -15
- package/.claude/skills/loop-grill/SKILL.md +163 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +125 -0
- package/README.md +29 -10
- package/agents/dev-loop.agent.md +11 -3
- package/agents/developer.agent.md +1 -1
- package/agents/docs.agent.md +1 -1
- package/agents/fixer.agent.md +1 -1
- package/agents/quality.agent.md +1 -1
- package/agents/refiner.agent.md +1 -1
- package/agents/review.agent.md +3 -2
- package/cli/index.mjs +35 -40
- package/extension/README.md +4 -4
- package/extension/checks.ts +1 -5
- package/extension/harness-types.ts +1 -0
- package/extension/index.ts +16 -1
- package/extension/pi-extension-adapter.ts +2 -0
- package/extension/presentation.ts +16 -13
- package/lib/dev-loops-core.mjs +141 -0
- package/package.json +10 -8
- package/scripts/claude/generate-claude-assets.mjs +15 -1
- package/scripts/github/capture-review-threads.mjs +2 -9
- package/scripts/github/comment-issue.mjs +174 -0
- package/scripts/github/create-label.mjs +133 -0
- package/scripts/github/detect-checkpoint-evidence.mjs +170 -14
- 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 +208 -0
- package/scripts/github/list-issues.mjs +184 -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 +14 -2
- 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 +8 -2
- 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 +31 -10
- package/scripts/github/verify-fresh-review-context.mjs +150 -31
- package/scripts/github/view-pr.mjs +150 -0
- package/scripts/github/write-gate-context.mjs +248 -61
- package/scripts/github/write-gate-findings-log.mjs +75 -1
- package/scripts/lib/jq-output.mjs +18 -0
- package/scripts/loop/_handoff-contract.mjs +1 -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 +135 -14
- package/scripts/loop/debt-remediate.mjs +24 -12
- package/scripts/loop/detect-change-scope.mjs +36 -7
- package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
- 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 +179 -66
- 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 +76 -7
- 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 +13 -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 +104 -0
- package/scripts/loop/steer-loop.mjs +29 -16
- package/scripts/loop/validate-pr-body-spec.mjs +207 -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 +441 -0
- package/scripts/projects/_resolve-project.mjs +148 -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 +116 -65
- package/scripts/projects/move-queue-item.mjs +28 -49
- package/scripts/projects/reconcile-queue.mjs +253 -0
- package/scripts/projects/reorder-queue-item.mjs +41 -47
- package/scripts/projects/resolve-active-board-item.mjs +255 -0
- 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 +179 -0
- 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 +125 -0
- package/skills/copilot-pr-followup/SKILL.md +24 -12
- package/skills/dev-loop/SKILL.md +22 -6
- package/skills/docs/acceptance-criteria-verification.md +6 -2
- package/skills/docs/anti-patterns.md +2 -1
- package/skills/docs/artifact-authority-contract.md +22 -4
- package/skills/docs/copilot-loop-operations.md +1 -1
- package/skills/docs/cross-harness-regression-contract.md +60 -0
- package/skills/docs/issue-intake-procedure.md +1 -0
- 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 +17 -1
- package/skills/docs/plan-file-contract.md +1 -1
- package/skills/docs/public-dev-loop-contract.md +1 -1
- package/skills/docs/release-runbook.md +45 -0
- package/skills/docs/retrospective-checkpoint-contract.md +90 -76
- package/skills/docs/ui-e2e-scoping-step.md +134 -0
- package/skills/docs/workflow-handoff-contract.md +39 -0
- package/skills/local-implementation/SKILL.md +26 -15
- package/skills/loop-grill/SKILL.md +165 -0
- 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
|
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
4
4
|
import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig } from "@dev-loops/core/config";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
|
-
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
7
7
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
8
8
|
import { truncateText } from "@dev-loops/core/bash-exit-one";
|
|
9
9
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
@@ -391,14 +391,7 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
|
|
|
391
391
|
options.inlineReason = smartTruncate(reason, MAX_GATE_COMMENT_EXCERPT_LENGTH);
|
|
392
392
|
continue;
|
|
393
393
|
}
|
|
394
|
-
if (token
|
|
395
|
-
options.jq = requireTokenValue(token, parseError);
|
|
396
|
-
continue;
|
|
397
|
-
}
|
|
398
|
-
if (token.name === "silent") {
|
|
399
|
-
options.silent = true;
|
|
400
|
-
continue;
|
|
401
|
-
}
|
|
394
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
402
395
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
403
396
|
}
|
|
404
397
|
// Default execution mode to inline_single_agent when omitted. inlineReason is
|
|
@@ -897,7 +890,14 @@ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRo
|
|
|
897
890
|
try {
|
|
898
891
|
// The PR is now a draft, so RUN_DRAFT_GATE is the legal action. Re-enter with
|
|
899
892
|
// the caller's full options; prIsDraft is now true so this branch is skipped.
|
|
900
|
-
|
|
893
|
+
// `_draftTransitionInProgress` guards against unbounded recursion: if GitHub's
|
|
894
|
+
// draft-state read still lags the conversion mutation on re-entry (isDraft reads
|
|
895
|
+
// false again), the reconcile branch must NOT fire a second time — it fails closed
|
|
896
|
+
// with a clear error instead of recursing indefinitely (exit 13). (#1020)
|
|
897
|
+
result = await upsertCheckpointVerdict(
|
|
898
|
+
{ ...options, _draftTransitionInProgress: true },
|
|
899
|
+
{ env, ghCommand, repoRoot },
|
|
900
|
+
);
|
|
901
901
|
} catch (error) {
|
|
902
902
|
if (conversion.alreadyDraft !== true) {
|
|
903
903
|
try {
|
|
@@ -1060,11 +1060,32 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1060
1060
|
if (
|
|
1061
1061
|
options.gate === "draft_gate"
|
|
1062
1062
|
&& !prIsDraft
|
|
1063
|
+
&& !options._draftTransitionInProgress
|
|
1063
1064
|
&& !coordination.draftGateAlreadySatisfied
|
|
1064
1065
|
&& coordination.allowedNextActions.includes(PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE)
|
|
1065
1066
|
) {
|
|
1066
1067
|
return await postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot });
|
|
1067
1068
|
}
|
|
1069
|
+
// Fail closed on a lagged draft-state read: we are re-entering FROM
|
|
1070
|
+
// postDraftGateViaDraftTransition (which just converted the PR to draft) yet the
|
|
1071
|
+
// coordination context still reports the PR as non-draft. Recursing would loop
|
|
1072
|
+
// indefinitely (the original #1020 hang → exit 13, error swallowed). Surface a
|
|
1073
|
+
// clear, actionable error instead so the operator knows the draft conversion did
|
|
1074
|
+
// not take (or GitHub's read lags the mutation) and can retry. (#1020)
|
|
1075
|
+
if (
|
|
1076
|
+
options.gate === "draft_gate"
|
|
1077
|
+
&& !prIsDraft
|
|
1078
|
+
&& options._draftTransitionInProgress
|
|
1079
|
+
) {
|
|
1080
|
+
throw new Error(
|
|
1081
|
+
`draft_gate self-heal for ${options.repo}#${options.pr} failed: the PR was converted to draft ` +
|
|
1082
|
+
`to post the verdict, but GitHub still reports it as non-draft on re-entry (draft-state read lagged ` +
|
|
1083
|
+
`the conversion mutation, or the conversion did not take). Not recursing. Re-run the draft_gate post ` +
|
|
1084
|
+
`once the PR reflects the draft state, or reconcile manually with ` +
|
|
1085
|
+
`\`gh pr ready ${options.pr} --repo ${options.repo}\` / ` +
|
|
1086
|
+
`\`node scripts/github/reconcile-draft-gate.mjs --repo ${options.repo} --pr ${options.pr}\`.`,
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1068
1089
|
if (gateActionForbidden) {
|
|
1069
1090
|
throw new Error(buildGateEntryRefusalError({ options, coordination }));
|
|
1070
1091
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { buildParseError, isDirectCliRun, formatCliError } from "../_core-helpers.mjs";
|
|
5
|
-
|
|
6
|
+
import { JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
7
|
+
const USAGE = `Usage: verify-fresh-review-context.mjs [--help] [--scope <name>] [--context-path <path>]
|
|
6
8
|
Verify that the current scoped-reviewer session has fresh context.
|
|
7
9
|
|
|
8
10
|
"Fresh" means the reviewer's context is the neutral gate-context builder
|
|
@@ -10,30 +12,60 @@ artifact (the build-once diff + adjacent-code bundle) plus its single review
|
|
|
10
12
|
angle, and explicitly NOT the main (orchestrating) agent's conversation/state
|
|
11
13
|
or a prior reviewer session's state. The injected neutral bundle is the
|
|
12
14
|
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.
|
|
15
|
+
cross-session state bleed by way of a per-(cwd, scope, round) sentinel: a first
|
|
16
|
+
run in a fresh session creates the sentinel and passes, a re-entry that finds an
|
|
17
|
+
existing sentinel for the same round fails closed. Seeding a reviewer with the
|
|
18
|
+
neutral bundle (a path/prompt, not a sentinel) never creates a sentinel, so it
|
|
19
|
+
never false-positives as contaminated.
|
|
20
|
+
|
|
21
|
+
Sentinels are per review ROUND, keyed by the current head SHA (\`git rev-parse
|
|
22
|
+
HEAD\`). A retry at a new head naturally gets a fresh sentinel (no manual clear
|
|
23
|
+
step), while a same-scope + same-head re-entry still fails closed. When git is
|
|
24
|
+
unavailable the sentinel is keyed by scope only (legacy behavior).
|
|
18
25
|
Options:
|
|
19
26
|
--scope <name> Unique reviewer scope (e.g. "draft-gate-coverage").
|
|
20
27
|
Must be non-empty, containing only alphanumeric
|
|
21
28
|
characters and hyphens. When provided, the sentinel
|
|
22
29
|
is scoped so parallel reviewers in the same working
|
|
23
30
|
directory do not trigger false contamination.
|
|
31
|
+
--context-path <path> Path to the seeded gate-context artifact this
|
|
32
|
+
reviewer must be reading from (the build-once bundle
|
|
33
|
+
written by write-gate-context.mjs, e.g.
|
|
34
|
+
tmp/gate-context/<repo-slug>/pr-<N>/<gate>-<headSha>.json).
|
|
35
|
+
Must resolve to a path within the reviewer's working
|
|
36
|
+
directory; a path that resolves OUTSIDE cwd (an absolute
|
|
37
|
+
or ..-escaping path pointing at another worktree's bundle,
|
|
38
|
+
which would defeat the worktree-locality guard) fails
|
|
39
|
+
closed (exit 1).
|
|
40
|
+
When provided, also fails closed (exit 1) if the artifact
|
|
41
|
+
is missing (ENOENT) from the reviewer's cwd. Per-angle
|
|
42
|
+
gate reviewers must run in the PR's actual worktree/head
|
|
43
|
+
(never an isolated worktree) so this gitignored,
|
|
44
|
+
worktree-local artifact is present; a missing artifact
|
|
45
|
+
means either a stale/isolated checkout or a skipped
|
|
46
|
+
preamble, and the reviewer must refuse to proceed
|
|
47
|
+
rather than silently reviewing without seeded context.
|
|
24
48
|
Output (stdout, JSON):
|
|
25
|
-
{ "ok": true, "fresh": true, "sentinelCreated": true }
|
|
26
|
-
{ "ok": true, "fresh":
|
|
49
|
+
{ "ok": true, "fresh": true, "sentinelCreated": true, "round": "<headSha|null>" }
|
|
50
|
+
{ "ok": true, "fresh": true, "sentinelCreated": true, "round": "...", "gateContextPath": "...", "gateContextPresent": true }
|
|
51
|
+
{ "ok": true, "fresh": false, "sentinelCreated": false, "round": "...", "reason": "..." }
|
|
52
|
+
{ "ok": true, "fresh": false, "sentinelCreated": false, "round": "...", "gateContextPath": "...", "gateContextPresent": false, "reason": "..." }
|
|
27
53
|
On error (stderr, JSON):
|
|
28
54
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
55
|
+
${JQ_OUTPUT_USAGE}
|
|
29
56
|
Exit codes:
|
|
30
57
|
0 Clean (first run)
|
|
31
|
-
1
|
|
32
|
-
|
|
58
|
+
1 Refuse to review: contaminated (prior session detected), OR (with
|
|
59
|
+
--context-path) the seeded gate-context artifact is missing or resolves
|
|
60
|
+
outside the reviewer's working directory
|
|
61
|
+
2 Usage or internal error, or invalid --jq filter`.trim();
|
|
33
62
|
const VALID_SCOPE_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
|
34
63
|
const parseError = buildParseError(USAGE);
|
|
35
|
-
|
|
36
|
-
|
|
64
|
+
// Resolve a `--flag <value>` argument. Returns null when the flag is absent,
|
|
65
|
+
// "" when it is present but the value is missing/empty/flag-like (a following
|
|
66
|
+
// `-`-prefixed token, which must not be silently consumed), else the value.
|
|
67
|
+
function resolveFlagValue(argv, flag) {
|
|
68
|
+
const idx = argv.indexOf(flag);
|
|
37
69
|
if (idx === -1) return null;
|
|
38
70
|
const val = argv[idx + 1];
|
|
39
71
|
if (val === undefined || val === "" || (val.length > 0 && val[0] === "-")) {
|
|
@@ -41,6 +73,9 @@ function resolveScope(argv) {
|
|
|
41
73
|
}
|
|
42
74
|
return val;
|
|
43
75
|
}
|
|
76
|
+
function resolveScope(argv) {
|
|
77
|
+
return resolveFlagValue(argv, "--scope");
|
|
78
|
+
}
|
|
44
79
|
function resolveValidatedScope(argv) {
|
|
45
80
|
const raw = resolveScope(argv);
|
|
46
81
|
if (raw === null) return null;
|
|
@@ -52,22 +87,47 @@ function resolveValidatedScope(argv) {
|
|
|
52
87
|
}
|
|
53
88
|
return raw;
|
|
54
89
|
}
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
90
|
+
function resolveContextPath(argv) {
|
|
91
|
+
return resolveFlagValue(argv, "--context-path");
|
|
92
|
+
}
|
|
93
|
+
// Round = the current head SHA, so a retry on a new head gets a fresh key while
|
|
94
|
+
// a same-head re-entry collides and fails closed. `git rev-parse HEAD` yields the
|
|
95
|
+
// same full SHA on every invocation for a given head, so the key is deterministic
|
|
96
|
+
// with no user input to spell it inconsistently. Returns null when git is
|
|
97
|
+
// unavailable (falls back to the legacy scope-only key).
|
|
98
|
+
function resolveHeadRound(cwd = process.cwd()) {
|
|
99
|
+
try {
|
|
100
|
+
const sha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
101
|
+
cwd,
|
|
102
|
+
encoding: "utf8",
|
|
103
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
104
|
+
}).trim();
|
|
105
|
+
return VALID_SCOPE_RE.test(sha) ? sha : null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null; // not a git repo / git unavailable
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function sentinelRelative(scope, round) {
|
|
111
|
+
const scopeSuffix = scope ? `-${scope}` : "";
|
|
112
|
+
const roundSuffix = round ? `-${round}` : "";
|
|
113
|
+
return path.join("tmp", `checkpoint-context-sentinel${scopeSuffix}${roundSuffix}.json`);
|
|
58
114
|
}
|
|
59
115
|
function legacySentinelRelative(scope) {
|
|
60
116
|
const suffix = scope ? `-${scope}` : "";
|
|
61
117
|
return path.join("tmp", `gate-review-context-sentinel${suffix}.json`);
|
|
62
118
|
}
|
|
63
|
-
async function checkSentinelExists(scope, cwd = process.cwd()) {
|
|
64
|
-
const sentinelPath = path.resolve(cwd, sentinelRelative(scope));
|
|
119
|
+
async function checkSentinelExists(scope, round, cwd = process.cwd()) {
|
|
120
|
+
const sentinelPath = path.resolve(cwd, sentinelRelative(scope, round));
|
|
65
121
|
try { await stat(sentinelPath); return { exists: true, path: sentinelPath, legacy: false }; } catch (err) {
|
|
66
122
|
if (err.code !== "ENOENT") throw err;
|
|
67
123
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
124
|
+
// Legacy names predate head-keyed rounds; only consult them for the
|
|
125
|
+
// scope-only key so a stale pre-round sentinel never blocks a new round.
|
|
126
|
+
if (!round) {
|
|
127
|
+
const legacyPath = path.resolve(cwd, legacySentinelRelative(scope));
|
|
128
|
+
try { await stat(legacyPath); return { exists: true, path: legacyPath, legacy: true }; } catch (err) {
|
|
129
|
+
if (err.code !== "ENOENT") throw err;
|
|
130
|
+
}
|
|
71
131
|
}
|
|
72
132
|
return { exists: false, path: sentinelPath, legacy: false };
|
|
73
133
|
}
|
|
@@ -78,27 +138,85 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
78
138
|
}
|
|
79
139
|
const scope = resolveValidatedScope(argv);
|
|
80
140
|
if (scope === undefined) return 2;
|
|
81
|
-
const
|
|
141
|
+
const contextPathArg = resolveContextPath(argv);
|
|
142
|
+
if (contextPathArg === "") {
|
|
143
|
+
process.stderr.write(`${formatCliError(
|
|
144
|
+
parseError("Invalid --context-path value: must be non-empty.")
|
|
145
|
+
)}\n`);
|
|
146
|
+
return 2;
|
|
147
|
+
}
|
|
148
|
+
const jqArg = resolveFlagValue(argv, "--jq");
|
|
149
|
+
if (jqArg === "") {
|
|
150
|
+
process.stderr.write(`${formatCliError(
|
|
151
|
+
parseError("Invalid --jq value: must be non-empty.")
|
|
152
|
+
)}\n`);
|
|
153
|
+
return 2;
|
|
154
|
+
}
|
|
155
|
+
const jq = jqArg === null ? undefined : jqArg;
|
|
156
|
+
const silent = argv.includes("--silent") || argv.includes("-s");
|
|
157
|
+
// Every branch below reports ok:true (the tool ran successfully); the
|
|
158
|
+
// fresh/contaminated verdict maps to the exit code via `ok` here so the
|
|
159
|
+
// shared --jq/--silent contract composes with the existing 0=fresh/1=refuse
|
|
160
|
+
// signal instead of always reading ok:true as success.
|
|
161
|
+
const finish = (payload, freshLike) => emitResult(payload, { jq, silent, ok: freshLike });
|
|
162
|
+
const round = resolveHeadRound();
|
|
163
|
+
if (contextPathArg !== null) {
|
|
164
|
+
const cwd = process.cwd();
|
|
165
|
+
const resolvedContextPath = path.resolve(cwd, contextPathArg);
|
|
166
|
+
// The guard proves the reviewer is in the working tree where the gitignored
|
|
167
|
+
// tmp/gate-context bundle was written. An absolute or ..-escaping path could
|
|
168
|
+
// stat the real bundle in ANOTHER (stale/isolated) worktree and pass — so a
|
|
169
|
+
// path resolving outside cwd fails closed, defeating that bypass.
|
|
170
|
+
const withinCwd =
|
|
171
|
+
resolvedContextPath === cwd || resolvedContextPath.startsWith(cwd + path.sep);
|
|
172
|
+
if (!withinCwd) {
|
|
173
|
+
return finish({
|
|
174
|
+
ok: true,
|
|
175
|
+
fresh: false,
|
|
176
|
+
sentinelCreated: false,
|
|
177
|
+
round: round ?? null,
|
|
178
|
+
gateContextPath: contextPathArg,
|
|
179
|
+
gateContextPresent: false,
|
|
180
|
+
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.`,
|
|
181
|
+
}, false);
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
await stat(resolvedContextPath);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (err.code !== "ENOENT") throw err;
|
|
187
|
+
return finish({
|
|
188
|
+
ok: true,
|
|
189
|
+
fresh: false,
|
|
190
|
+
sentinelCreated: false,
|
|
191
|
+
round: round ?? null,
|
|
192
|
+
gateContextPath: contextPathArg,
|
|
193
|
+
gateContextPresent: false,
|
|
194
|
+
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.`,
|
|
195
|
+
}, false);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const sentinelPath = path.resolve(process.cwd(), sentinelRelative(scope, round));
|
|
82
199
|
try {
|
|
83
200
|
await mkdir(path.dirname(sentinelPath), { recursive: true });
|
|
84
201
|
} catch (err) {
|
|
85
202
|
process.stderr.write(`${formatCliError(err)}\n`);
|
|
86
203
|
return 2;
|
|
87
204
|
}
|
|
88
|
-
const existing = await checkSentinelExists(scope);
|
|
205
|
+
const existing = await checkSentinelExists(scope, round);
|
|
89
206
|
if (existing.exists) {
|
|
90
|
-
|
|
207
|
+
return finish({
|
|
91
208
|
ok: true,
|
|
92
209
|
fresh: false,
|
|
93
210
|
sentinelCreated: false,
|
|
211
|
+
round: round ?? null,
|
|
94
212
|
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;
|
|
213
|
+
}, false);
|
|
97
214
|
}
|
|
98
215
|
const sentinel = {
|
|
99
216
|
createdAt: new Date().toISOString(),
|
|
100
217
|
pid: process.pid,
|
|
101
218
|
...(scope ? { scope } : {}),
|
|
219
|
+
...(round ? { round } : {}),
|
|
102
220
|
};
|
|
103
221
|
try {
|
|
104
222
|
await writeFile(sentinelPath, JSON.stringify(sentinel, null, 2) + "\n", {
|
|
@@ -107,23 +225,24 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
107
225
|
});
|
|
108
226
|
} catch (err) {
|
|
109
227
|
if (err.code === "EEXIST") {
|
|
110
|
-
|
|
228
|
+
return finish({
|
|
111
229
|
ok: true,
|
|
112
230
|
fresh: false,
|
|
113
231
|
sentinelCreated: false,
|
|
232
|
+
round: round ?? null,
|
|
114
233
|
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;
|
|
234
|
+
}, false);
|
|
117
235
|
}
|
|
118
236
|
process.stderr.write(`${formatCliError(err)}\n`);
|
|
119
237
|
return 2;
|
|
120
238
|
}
|
|
121
|
-
|
|
239
|
+
return finish({
|
|
122
240
|
ok: true,
|
|
123
241
|
fresh: true,
|
|
124
242
|
sentinelCreated: true,
|
|
125
|
-
|
|
126
|
-
|
|
243
|
+
round: round ?? null,
|
|
244
|
+
...(contextPathArg !== null ? { gateContextPath: contextPathArg, gateContextPresent: true } : {}),
|
|
245
|
+
}, true);
|
|
127
246
|
}
|
|
128
247
|
if (isDirectCliRun(import.meta.url)) {
|
|
129
248
|
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
|
+
}
|