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.
Files changed (177) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +2 -1
  3. package/.claude/agents/review.md +2 -1
  4. package/.claude/commands/loop-auto.md +7 -0
  5. package/.claude/commands/loop-continue.md +15 -0
  6. package/.claude/commands/loop-enqueue.md +22 -0
  7. package/.claude/commands/loop-grill.md +17 -0
  8. package/.claude/commands/loop-info.md +7 -0
  9. package/.claude/commands/loop-queue-status.md +24 -0
  10. package/.claude/commands/loop-start-spike.md +16 -0
  11. package/.claude/commands/loop-start.md +7 -0
  12. package/.claude/commands/loop-status.md +6 -0
  13. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  14. package/.claude/hooks/_hook-decisions.mjs +138 -15
  15. package/.claude/hooks/_run-context.mjs +11 -4
  16. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  17. package/.claude/skills/copilot-pr-followup/SKILL.md +24 -12
  18. package/.claude/skills/dev-loop/SKILL.md +19 -10
  19. package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
  20. package/.claude/skills/docs/anti-patterns.md +2 -1
  21. package/.claude/skills/docs/artifact-authority-contract.md +22 -4
  22. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  23. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  24. package/.claude/skills/docs/issue-intake-procedure.md +1 -0
  25. package/.claude/skills/docs/local-planning-flow.md +1 -1
  26. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  27. package/.claude/skills/docs/merge-preconditions.md +17 -1
  28. package/.claude/skills/docs/plan-file-contract.md +1 -1
  29. package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
  30. package/.claude/skills/docs/release-runbook.md +45 -0
  31. package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
  32. package/.claude/skills/docs/ui-e2e-scoping-step.md +134 -0
  33. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  34. package/.claude/skills/local-implementation/SKILL.md +26 -15
  35. package/.claude/skills/loop-grill/SKILL.md +163 -0
  36. package/AGENTS.md +1 -0
  37. package/CHANGELOG.md +125 -0
  38. package/README.md +29 -10
  39. package/agents/dev-loop.agent.md +11 -3
  40. package/agents/developer.agent.md +1 -1
  41. package/agents/docs.agent.md +1 -1
  42. package/agents/fixer.agent.md +1 -1
  43. package/agents/quality.agent.md +1 -1
  44. package/agents/refiner.agent.md +1 -1
  45. package/agents/review.agent.md +3 -2
  46. package/cli/index.mjs +35 -40
  47. package/extension/README.md +4 -4
  48. package/extension/checks.ts +1 -5
  49. package/extension/harness-types.ts +1 -0
  50. package/extension/index.ts +16 -1
  51. package/extension/pi-extension-adapter.ts +2 -0
  52. package/extension/presentation.ts +16 -13
  53. package/lib/dev-loops-core.mjs +141 -0
  54. package/package.json +10 -8
  55. package/scripts/claude/generate-claude-assets.mjs +15 -1
  56. package/scripts/github/capture-review-threads.mjs +2 -9
  57. package/scripts/github/comment-issue.mjs +174 -0
  58. package/scripts/github/create-label.mjs +133 -0
  59. package/scripts/github/detect-checkpoint-evidence.mjs +170 -14
  60. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  61. package/scripts/github/edit-pr.mjs +259 -0
  62. package/scripts/github/fetch-ci-logs.mjs +208 -0
  63. package/scripts/github/list-issues.mjs +184 -0
  64. package/scripts/github/manage-sub-issues.mjs +46 -10
  65. package/scripts/github/offer-human-handoff.mjs +8 -5
  66. package/scripts/github/post-gate-findings.mjs +14 -2
  67. package/scripts/github/probe-ci-status.mjs +8 -2
  68. package/scripts/github/probe-copilot-review.mjs +21 -14
  69. package/scripts/github/ready-for-review.mjs +26 -8
  70. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  71. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  72. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  73. package/scripts/github/request-copilot-review.mjs +8 -2
  74. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  75. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  76. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  77. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  78. package/scripts/github/upsert-checkpoint-verdict.mjs +31 -10
  79. package/scripts/github/verify-fresh-review-context.mjs +150 -31
  80. package/scripts/github/view-pr.mjs +150 -0
  81. package/scripts/github/write-gate-context.mjs +248 -61
  82. package/scripts/github/write-gate-findings-log.mjs +75 -1
  83. package/scripts/lib/jq-output.mjs +18 -0
  84. package/scripts/loop/_handoff-contract.mjs +1 -0
  85. package/scripts/loop/_post-convergence-change.mjs +211 -0
  86. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  87. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  88. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  89. package/scripts/loop/check-retro-tooling.mjs +14 -3
  90. package/scripts/loop/checkpoint-contract.mjs +7 -4
  91. package/scripts/loop/cleanup-worktree.mjs +12 -3
  92. package/scripts/loop/conductor-monitor.mjs +12 -18
  93. package/scripts/loop/copilot-pr-handoff.mjs +135 -14
  94. package/scripts/loop/debt-remediate.mjs +24 -12
  95. package/scripts/loop/detect-change-scope.mjs +36 -7
  96. package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
  97. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  98. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  99. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  100. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  101. package/scripts/loop/detect-pr-gate-coordination-state.mjs +179 -66
  102. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  103. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  104. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  105. package/scripts/loop/ensure-worktree.mjs +8 -3
  106. package/scripts/loop/info.mjs +12 -4
  107. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  108. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  109. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  110. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  111. package/scripts/loop/inspect-run.mjs +8 -2
  112. package/scripts/loop/outer-loop.mjs +8 -4
  113. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  114. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  115. package/scripts/loop/pre-flight-gate.mjs +9 -9
  116. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  117. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  118. package/scripts/loop/provision-worktree.mjs +74 -3
  119. package/scripts/loop/resolve-dev-loop-startup.mjs +76 -7
  120. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  121. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  122. package/scripts/loop/run-conductor-cycle.mjs +13 -2
  123. package/scripts/loop/run-queue.mjs +18 -9
  124. package/scripts/loop/run-refinement-audit.mjs +8 -9
  125. package/scripts/loop/run-watch-cycle.mjs +2 -9
  126. package/scripts/loop/sanctioned-commands.mjs +104 -0
  127. package/scripts/loop/steer-loop.mjs +29 -16
  128. package/scripts/loop/validate-pr-body-spec.mjs +207 -0
  129. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  130. package/scripts/pages/build-site.mjs +59 -8
  131. package/scripts/pages/build-state-atlas.mjs +441 -0
  132. package/scripts/projects/_resolve-project.mjs +148 -0
  133. package/scripts/projects/add-queue-item.mjs +60 -54
  134. package/scripts/projects/archive-done-items.mjs +49 -84
  135. package/scripts/projects/ensure-queue-board.mjs +10 -36
  136. package/scripts/projects/list-queue-items.mjs +116 -65
  137. package/scripts/projects/move-queue-item.mjs +28 -49
  138. package/scripts/projects/reconcile-queue.mjs +253 -0
  139. package/scripts/projects/reorder-queue-item.mjs +41 -47
  140. package/scripts/projects/resolve-active-board-item.mjs +255 -0
  141. package/scripts/projects/sync-item-status.mjs +15 -10
  142. package/scripts/refine/_refine-helpers.mjs +21 -5
  143. package/scripts/refine/exit-spike.mjs +18 -8
  144. package/scripts/refine/promote-plan.mjs +22 -16
  145. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  146. package/scripts/refine/refine-plan-file.mjs +13 -4
  147. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  148. package/scripts/refine/scaffold-spike-file.mjs +179 -0
  149. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  150. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  151. package/scripts/refine/validate-plan-file.mjs +1 -1
  152. package/scripts/refine/validate-spike-file.mjs +1 -1
  153. package/scripts/refine/verify.mjs +11 -6
  154. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  155. package/scripts/release/extract-changelog-section.mjs +125 -0
  156. package/skills/copilot-pr-followup/SKILL.md +24 -12
  157. package/skills/dev-loop/SKILL.md +22 -6
  158. package/skills/docs/acceptance-criteria-verification.md +6 -2
  159. package/skills/docs/anti-patterns.md +2 -1
  160. package/skills/docs/artifact-authority-contract.md +22 -4
  161. package/skills/docs/copilot-loop-operations.md +1 -1
  162. package/skills/docs/cross-harness-regression-contract.md +60 -0
  163. package/skills/docs/issue-intake-procedure.md +1 -0
  164. package/skills/docs/local-planning-flow.md +1 -1
  165. package/skills/docs/local-planning-worked-example.md +1 -1
  166. package/skills/docs/merge-preconditions.md +17 -1
  167. package/skills/docs/plan-file-contract.md +1 -1
  168. package/skills/docs/public-dev-loop-contract.md +1 -1
  169. package/skills/docs/release-runbook.md +45 -0
  170. package/skills/docs/retrospective-checkpoint-contract.md +90 -76
  171. package/skills/docs/ui-e2e-scoping-step.md +134 -0
  172. package/skills/docs/workflow-handoff-contract.md +39 -0
  173. package/skills/local-implementation/SKILL.md +26 -15
  174. package/skills/loop-grill/SKILL.md +165 -0
  175. package/scripts/loop/conductor.mjs +0 -233
  176. package/scripts/loop/detect-stale-runner.mjs +0 -265
  177. 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.name === "jq") {
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
- result = await upsertCheckpointVerdict(options, { env, ghCommand, repoRoot });
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
- const USAGE = `Usage: verify-fresh-review-context.mjs [--help] [--scope <name>]
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 run in
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 neutral bundle (a
16
- path/prompt, not a sentinel) never creates a sentinel, so it never
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": false, "sentinelCreated": false, "reason": "..." }
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 Contaminated (prior session detected)
32
- 2 Usage or internal error`.trim();
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
- function resolveScope(argv) {
36
- const idx = argv.indexOf("--scope");
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 sentinelRelative(scope) {
56
- const suffix = scope ? `-${scope}` : "";
57
- return path.join("tmp", `checkpoint-context-sentinel${suffix}.json`);
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
- const legacyPath = path.resolve(cwd, legacySentinelRelative(scope));
69
- try { await stat(legacyPath); return { exists: true, path: legacyPath, legacy: true }; } catch (err) {
70
- if (err.code !== "ENOENT") throw err;
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 sentinelPath = path.resolve(process.cwd(), sentinelRelative(scope));
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
- process.stdout.write(JSON.stringify({
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
- }) + "\n");
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
- process.stdout.write(JSON.stringify({
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
- }) + "\n");
116
- return 1;
234
+ }, false);
117
235
  }
118
236
  process.stderr.write(`${formatCliError(err)}\n`);
119
237
  return 2;
120
238
  }
121
- process.stdout.write(JSON.stringify({
239
+ return finish({
122
240
  ok: true,
123
241
  fresh: true,
124
242
  sentinelCreated: true,
125
- }) + "\n");
126
- return 0;
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
+ }