pr-shepherd 0.59.0 → 0.59.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +38 -45
  3. package/bin/cli/help-command-pages.d.mts +2 -2
  4. package/bin/cli/help-iterate-poll-pages.d.mts +2 -2
  5. package/bin/cli/help-iterate-poll-pages.mjs +5 -5
  6. package/bin/cli/help-top-page.d.mts +1 -1
  7. package/bin/cli/help-top-page.mjs +2 -2
  8. package/bin/cli/help.d.mts +3 -3
  9. package/bin/cli/iterate-instructions.mjs +1 -31
  10. package/bin/cli/iterate-lean.mjs +0 -1
  11. package/bin/commands/event-mode.mjs +3 -7
  12. package/bin/commands/iterate/index.mjs +23 -10
  13. package/bin/commands/next-check.d.mts +0 -2
  14. package/bin/commands/next-check.mjs +1 -1
  15. package/bin/commands/poll-summary-explicit-instructions.d.mts +2 -0
  16. package/bin/commands/poll-summary-explicit-instructions.mjs +18 -2
  17. package/bin/commands/poll-summary-instructions.mjs +33 -11
  18. package/bin/commands/stack-drain.mjs +1 -2
  19. package/bin/commands/stack-work.d.mts +8 -21
  20. package/bin/commands/stack-work.mjs +14 -38
  21. package/bin/exit-codes.d.mts +1 -1
  22. package/bin/exit-codes.mjs +2 -1
  23. package/bin/github/poll-summary-projector.mjs +3 -1
  24. package/bin/github/poll-summary-route.mjs +6 -7
  25. package/bin/journal/journal-container.d.mts +6 -0
  26. package/bin/journal/journal-container.mjs +33 -0
  27. package/bin/journal/markdown-details.d.mts +15 -0
  28. package/bin/journal/markdown-details.mjs +92 -0
  29. package/bin/journal/markdown-line.d.mts +2 -0
  30. package/bin/journal/markdown-line.mjs +3 -3
  31. package/bin/journal/reconcile.mjs +14 -31
  32. package/bin/types/iterate.d.mts +2 -3
  33. package/bin/types/merge-requirements.d.mts +0 -7
  34. package/package.json +1 -1
  35. package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
  36. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  37. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  38. package/plugins/pr-shepherd/.mcp.json +1 -1
  39. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +8 -6
  40. package/plugins/pr-shepherd/skills/pr-shepherd/references/cloud-event-loop.md +1 -1
  41. package/plugins/pr-shepherd/skills/pr-shepherd/references/draft-handoff.md +38 -0
  42. package/bin/commands/iterate/parent-first.d.mts +0 -8
  43. package/bin/commands/iterate/parent-first.mjs +0 -13
@@ -1,12 +1,24 @@
1
1
  import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
2
+ import { isDraftReadinessVerification } from "./stack-work.mjs";
2
3
  /** Every selected row already returned `CANCEL` or `ESCALATE`: no autonomous work remains. */
3
4
  export function isHandedOffSelection(result) {
4
5
  return (result.prs.length > 0 &&
5
6
  result.prs.every((item) => item.action === "cancel" || item.action === "escalate"));
6
7
  }
8
+ /** A final full-review probe can finish this selection without another aggregate read. */
9
+ export function onlyDraftVerificationSelection(result) {
10
+ return (result.prs.some(isDraftReadinessVerification) &&
11
+ result.prs.every((item) => item.action === "cancel" ||
12
+ item.action === "escalate" ||
13
+ isDraftReadinessVerification(item)));
14
+ }
7
15
  export function explicitInstructions(result) {
8
16
  if (result.reason === "all_terminal")
9
- return ["1. Stop — every selected PR is terminal."];
17
+ return [
18
+ result.prs.some((item) => item.reasons.includes("draft-auto-mark-ready-disabled"))
19
+ ? "1. Stop — every selected PR is complete for this Shepherd run."
20
+ : "1. Stop — every selected PR is terminal.",
21
+ ];
10
22
  if (result.quotaWarning && result.reason !== "actionable") {
11
23
  return [
12
24
  buildQuotaAwareContinuation(result.quotaWarning, "1. This aggregate selection is non-terminal. Before continuing,"),
@@ -20,7 +32,11 @@ export function explicitInstructions(result) {
20
32
  "2. Follow each selected one-PR poll's `## Instructions` until it returns `CANCEL` or `ESCALATE`.",
21
33
  "3. Run this aggregate poll again after selected work completes; one row's `ESCALATE` does not stop work on other rows.",
22
34
  ];
23
- if (isHandedOffSelection(result)) {
35
+ if (onlyDraftVerificationSelection(result)) {
36
+ instructions[2] =
37
+ "3. If every draft verification probe returns `[CANCEL]` with reason `auto-mark-ready-disabled`, stop this selection and report any `ESCALATE` rows; leave the drafts unchanged. If any probe returns `[ESCALATE]`, report that handoff and stop after the other probes finish. Otherwise follow the probes' instructions and rerun this aggregate selector when that work completes.";
38
+ }
39
+ else if (isHandedOffSelection(result)) {
24
40
  instructions[2] =
25
41
  "3. Stop after reporting each `ESCALATE` handoff; every selected row is `CANCEL` or `ESCALATE`, so do not run this aggregate poll again.";
26
42
  }
@@ -2,7 +2,7 @@
2
2
  import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
3
3
  import { explicitInstructions } from "./poll-summary-explicit-instructions.mjs";
4
4
  import { appendAutonomousInstructions, appendHumanHandoffInstructions, findHumanHandoffs, idleWaitPlan, isStackLayerReady, planPrefixDrain, retargetWaitPlan, stackPosition, } from "./stack-drain.mjs";
5
- import { appendMarkReadyInstructions, appendStackRerunInstruction, splitStackWork, } from "./stack-work.mjs";
5
+ import { appendStackRerunInstruction, onlyDraftReadinessVerification, splitStackWork, } from "./stack-work.mjs";
6
6
  /** Keep aggregate JSON, Markdown, and MCP instructions on one projection. */
7
7
  export function withPollSummaryInstructions(result, mergeRequested) {
8
8
  return planPollSummary(result, mergeRequested).result;
@@ -19,7 +19,8 @@ export function planPollSummary(result, mergeRequested) {
19
19
  prs: prs.map((item) => {
20
20
  const needsOwnSession = item.state === "OPEN" &&
21
21
  (!isStackLayerReady(item) || staleChildren.has(item.pr)) &&
22
- ["cancel", "merge"].includes(item.action);
22
+ ["cancel", "merge"].includes(item.action) &&
23
+ (!item.reasons.includes("draft-auto-mark-ready-disabled") || staleChildren.has(item.pr));
23
24
  const layer = needsOwnSession
24
25
  ? {
25
26
  ...item,
@@ -51,6 +52,11 @@ export function planPollSummary(result, mergeRequested) {
51
52
  reasons: [...layer.reasons, "unverified-stack-state"],
52
53
  };
53
54
  }
55
+ if (layer.reasons.includes("draft-auto-mark-ready-disabled") &&
56
+ !staleChildren.has(layer.pr)) {
57
+ const { pollCommand: _pollCommand, pollProbe: _pollProbe, ...held } = layer;
58
+ return held;
59
+ }
54
60
  return layer;
55
61
  }),
56
62
  };
@@ -84,9 +90,9 @@ function planStack(result, mergeRequested) {
84
90
  const work = splitStackWork(open.filter((item) => (!isStackLayerReady(item) || staleChildren.has(item.pr)) && item.action !== "escalate"), staleChildren);
85
91
  const runnableCandidates = work.sessions.filter((item) => item.pollCommand);
86
92
  const missingCommands = work.sessions.filter((item) => !item.pollCommand);
87
- const agentWork = runnableCandidates.length > 0 || work.markReady.length > 0;
93
+ const agentWork = runnableCandidates.length > 0;
88
94
  // Only owned work defers a human handoff: rerunning cannot advance another author's layer.
89
- const ownedWork = [...runnableCandidates, ...work.markReady].some((item) => item.owned);
95
+ const ownedWork = runnableCandidates.some((item) => item.owned);
90
96
  const drain = planPrefixDrain(result, mergeRequested);
91
97
  if (drain)
92
98
  return drain;
@@ -94,14 +100,17 @@ function planStack(result, mergeRequested) {
94
100
  if (handoffs) {
95
101
  const instructions = [];
96
102
  appendAutonomousInstructions(instructions, runnableCandidates);
97
- appendMarkReadyInstructions(instructions, work.markReady);
98
103
  const stop = !ownedWork;
99
104
  appendHumanHandoffInstructions(instructions, handoffs, stop);
100
105
  for (const item of missingCommands) {
101
106
  instructions.push(`${instructions.length + 1}. PR #${item.pr} needs a one-PR session, but Shepherd could not produce its command. Ask for direction.`);
102
107
  }
103
108
  if (ownedWork) {
104
- appendStackRerunInstruction(instructions, [...runnableCandidates, ...work.markReady], "After the listed one-PR sessions, rerun this same `--stack` selector. Stop for the human handoff only when no autonomous shepherding remains.");
109
+ const rerun = onlyDraftReadinessVerification(work.sessions, work.idle) &&
110
+ work.sessions.every((item) => item.owned)
111
+ ? "If every listed one-PR probe returns `[CANCEL]` with reason `auto-mark-ready-disabled`, report the human handoff above and stop this selector. If any probe returns `[ESCALATE]`, report that handoff and stop after the other probes finish. Otherwise follow each probe's instructions and rerun this same `--stack` selector when that work completes."
112
+ : "After the listed one-PR sessions, rerun this same `--stack` selector. Stop for the human handoff only when no autonomous shepherding remains.";
113
+ appendStackRerunInstruction(instructions, runnableCandidates, rerun);
105
114
  }
106
115
  return { action: stop ? "escalate" : "shepherd", stackMergeable: false, instructions };
107
116
  }
@@ -116,21 +125,34 @@ function planStack(result, mergeRequested) {
116
125
  if (missingCommands.length > 0) {
117
126
  const instructions = [];
118
127
  appendAutonomousInstructions(instructions, runnableCandidates);
119
- appendMarkReadyInstructions(instructions, work.markReady);
120
128
  for (const item of missingCommands) {
121
129
  instructions.push(`${instructions.length + 1}. PR #${item.pr} needs a one-PR session, but Shepherd could not produce its command. ${ownedWork ? "After autonomous shepherding, ask" : "Stop and ask"} for direction.`);
122
130
  }
123
131
  if (ownedWork) {
124
- appendStackRerunInstruction(instructions, [...runnableCandidates, ...work.markReady], "After the listed one-PR sessions, rerun this same `--stack` selector. Stop for the human handoff only when no autonomous shepherding remains.");
132
+ appendStackRerunInstruction(instructions, runnableCandidates, "After the listed one-PR sessions, rerun this same `--stack` selector. Stop for the human handoff only when no autonomous shepherding remains.");
125
133
  }
126
134
  return { action: ownedWork ? "shepherd" : "escalate", stackMergeable: false, instructions };
127
135
  }
128
- if (!agentWork)
136
+ if (!agentWork) {
137
+ if (work.held.length > 0 && work.idle.length === 0) {
138
+ return {
139
+ action: "cancel",
140
+ stackMergeable: false,
141
+ instructions: [
142
+ `1. Stop shepherding this stack. Automatic mark-ready is disabled and ${work.held.map((item) => `PR #${item.pr}`).join(", ")} ${work.held.length === 1 ? "is" : "are"} clean but still in draft.`,
143
+ ],
144
+ };
145
+ }
129
146
  return idleWaitPlan(work.idle);
147
+ }
130
148
  const instructions = [];
131
149
  appendAutonomousInstructions(instructions, work.sessions);
132
- appendMarkReadyInstructions(instructions, work.markReady);
133
- const action = appendStackRerunInstruction(instructions, [...work.sessions, ...work.markReady], "After the selected one-PR sessions, rerun this same `--stack` selector.");
150
+ if (onlyDraftReadinessVerification(work.sessions, work.idle) &&
151
+ work.sessions.every((item) => item.owned)) {
152
+ instructions.push(`${instructions.length + 1}. If every listed one-PR probe returns \`[CANCEL]\` with reason \`auto-mark-ready-disabled\`, stop shepherding this stack and leave those drafts unchanged. If any probe returns \`[ESCALATE]\`, report that handoff and stop after the other probes finish. Otherwise follow each probe's instructions and rerun this same \`--stack\` selector when that work completes.`);
153
+ return { action: "shepherd", stackMergeable: false, instructions };
154
+ }
155
+ const action = appendStackRerunInstruction(instructions, work.sessions, "After the selected one-PR sessions, rerun this same `--stack` selector.");
134
156
  return { action, stackMergeable: false, instructions };
135
157
  }
136
158
  if (open.some((item) => item.isInMergeQueue)) {
@@ -2,7 +2,7 @@ import { findHumanHandoffs, appendHumanHandoffInstructions } from "./stack-hando
2
2
  export { findHumanHandoffs, appendHumanHandoffInstructions } from "./stack-handoffs.mjs";
3
3
  import { stackMergeFlag } from "./stack-merge-flag.mjs";
4
4
  import { stackLayerBlockReason } from "./stack-layer-readiness.mjs";
5
- import { appendMarkReadyInstructions, splitStackWork } from "./stack-work.mjs";
5
+ import { splitStackWork } from "./stack-work.mjs";
6
6
  import { getGithubTransport } from "../github/transport.mjs";
7
7
  import { buildMergeCommandPlan, renderMergeCommand } from "./iterate/merge.mjs";
8
8
  function readyPrefixTop(result) {
@@ -89,7 +89,6 @@ export function planPrefixDrain(result, mergeRequested) {
89
89
  instructions[0] = `1. PR #${top.pr} is the highest open layer of stack #${top.stack.number} in \`${result.repo}\` whose open lower layers are all ready. Run \`GH_REPO=${result.repo} gh stack merge ${top.pr} --yes ${method.flag}\` to merge ${span}. If \`gh stack\` is an unknown command, run \`gh extension install github/gh-stack\`, then rerun that merge command. Do not rebase, push, or run \`gh stack push\`.`;
90
90
  }
91
91
  appendAutonomousInstructions(instructions, above.sessions);
92
- appendMarkReadyInstructions(instructions, above.markReady);
93
92
  const handoffs = findHumanHandoffs(result);
94
93
  if (handoffs)
95
94
  appendHumanHandoffInstructions(instructions, handoffs, false);
@@ -1,37 +1,24 @@
1
1
  import type { PollSummaryItem } from "../types.mts";
2
- type ProbedLayer = PollSummaryItem & {
3
- pollCommand: string;
4
- pollProbe: true;
5
- };
6
- /**
7
- * The one-PR hold reason for a `READY` draft that only the disabled setting keeps in draft. The
8
- * probe re-reads the draft, so this phrase, not a bare `[WAIT]`, proves it is still clean.
9
- */
10
- export declare const AUTO_MARK_READY_DISABLED_HOLD = "automatic mark-ready is disabled for this session";
11
2
  /** How the stack selector routes the autonomous layers that still need work. */
12
3
  export interface StackWork {
13
4
  /** Layers that need a one-PR Shepherd session, with or without a known command. */
14
5
  sessions: PollSummaryItem[];
15
- /** Clean, unblocked drafts that the agent marks ready because the poll loop will not. */
16
- markReady: ProbedLayer[];
6
+ /** Clean drafts held by the disabled ready transition. No more work is scheduled for them. */
7
+ held: PollSummaryItem[];
17
8
  /** Layers whose bounded probe could only report that they are still waiting. */
18
- idle: ProbedLayer[];
9
+ idle: PollSummaryItem[];
19
10
  }
11
+ export declare function isDraftReadinessVerification(item: PollSummaryItem): boolean;
12
+ /** A full one-PR check is the final task when the compact review sample was incomplete. */
13
+ export declare function onlyDraftReadinessVerification(sessions: PollSummaryItem[], idle: PollSummaryItem[]): boolean;
20
14
  /**
21
- * Split autonomous stack candidates by what the agent can do for each. A bounded probe cannot
22
- * mark a draft ready, so rerunning it for a waiting layer cannot change the stack. A probed wait
23
- * kept in draft only by the disabled mark-ready setting is the agent's ready-for-review step.
15
+ * Split autonomous stack candidates by what the agent can do for each. A clean draft held by
16
+ * disabled mark-ready is complete for this run. Other bounded waits stay idle.
24
17
  * A stale-ancestry child keeps its session, which returns the ancestry repair.
25
18
  */
26
19
  export declare function splitStackWork(candidates: PollSummaryItem[], staleChildren: ReadonlySet<number>): StackWork;
27
- /**
28
- * The poll loop leaves a disabled draft's ready transition to the agent. The bounded probe runs
29
- * first because only the one-PR session reads the full review context.
30
- */
31
- export declare function appendMarkReadyInstructions(instructions: string[], layers: ProbedLayer[]): void;
32
20
  /**
33
21
  * The closing step after routed sessions: rerun the selector, or stop when nothing is owned.
34
22
  * Returns the plan action: a stop is a handoff, so no rerun, quota cadence, or wake-up follows it.
35
23
  */
36
24
  export declare function appendStackRerunInstruction(instructions: string[], layers: PollSummaryItem[], rerun: string): "shepherd" | "escalate";
37
- export {};
@@ -1,24 +1,23 @@
1
- import { buildPrShepherdCommand } from "../cli/runner.mjs";
2
- import { formatPrUrl } from "../pr-reference.mjs";
3
- /**
4
- * The one-PR hold reason for a `READY` draft that only the disabled setting keeps in draft. The
5
- * probe re-reads the draft, so this phrase, not a bare `[WAIT]`, proves it is still clean.
6
- */
7
- export const AUTO_MARK_READY_DISABLED_HOLD = "automatic mark-ready is disabled for this session";
1
+ export function isDraftReadinessVerification(item) {
2
+ return item.reasons?.includes("draft-readiness-verification-required") === true;
3
+ }
4
+ /** A full one-PR check is the final task when the compact review sample was incomplete. */
5
+ export function onlyDraftReadinessVerification(sessions, idle) {
6
+ return sessions.length > 0 && idle.length === 0 && sessions.every(isDraftReadinessVerification);
7
+ }
8
8
  /**
9
- * Split autonomous stack candidates by what the agent can do for each. A bounded probe cannot
10
- * mark a draft ready, so rerunning it for a waiting layer cannot change the stack. A probed wait
11
- * kept in draft only by the disabled mark-ready setting is the agent's ready-for-review step.
9
+ * Split autonomous stack candidates by what the agent can do for each. A clean draft held by
10
+ * disabled mark-ready is complete for this run. Other bounded waits stay idle.
12
11
  * A stale-ancestry child keeps its session, which returns the ancestry repair.
13
12
  */
14
13
  export function splitStackWork(candidates, staleChildren) {
15
- const work = { sessions: [], markReady: [], idle: [] };
14
+ const work = { sessions: [], held: [], idle: [] };
16
15
  for (const item of candidates) {
17
- if (!isProbed(item) || item.action !== "wait" || staleChildren.has(item.pr)) {
18
- work.sessions.push(item);
16
+ if (item.reasons.includes("draft-auto-mark-ready-disabled") && !staleChildren.has(item.pr)) {
17
+ work.held.push(item);
19
18
  }
20
- else if (item.reasons.includes("draft-auto-mark-ready-disabled")) {
21
- work.markReady.push(item);
19
+ else if (!isProbed(item) || item.action !== "wait" || staleChildren.has(item.pr)) {
20
+ work.sessions.push(item);
22
21
  }
23
22
  else {
24
23
  work.idle.push(item);
@@ -26,29 +25,6 @@ export function splitStackWork(candidates, staleChildren) {
26
25
  }
27
26
  return work;
28
27
  }
29
- /**
30
- * The poll loop leaves a disabled draft's ready transition to the agent. The bounded probe runs
31
- * first because only the one-PR session reads the full review context.
32
- */
33
- export function appendMarkReadyInstructions(instructions, layers) {
34
- for (const item of layers) {
35
- if (!item.owned) {
36
- instructions.push(`${instructions.length + 1}. PR #${item.pr} is not owned. Leave its draft state unchanged.`);
37
- continue;
38
- }
39
- if (item.transport === "rest") {
40
- const command = buildPrShepherdCommand([
41
- "iterate",
42
- formatPrUrl(item.repo, item.pr),
43
- "--transport",
44
- "rest",
45
- ]).text;
46
- instructions.push(`${instructions.length + 1}. Run \`${item.pollCommand}\` first for PR #${item.pr}. If it returns \`[WAIT]\` saying the draft is held only because automatic mark-ready is disabled, run \`${command}\` to authorize the CCR ready transition; otherwise follow the probe's instructions.`);
47
- continue;
48
- }
49
- instructions.push(`${instructions.length + 1}. Automatic mark-ready is disabled, so marking PR #${item.pr} ready for review is your step. Run \`${item.pollCommand}\` first. If it returns \`[WAIT]\` saying PR #${item.pr} stays in draft because ${AUTO_MARK_READY_DISABLED_HOLD}, run \`gh pr ready ${item.pr} -R ${item.repo}\`; otherwise complete its instructions and leave PR #${item.pr} in draft this round.`);
50
- }
51
- }
52
28
  function isProbed(item) {
53
29
  return item.pollProbe === true && item.pollCommand !== undefined;
54
30
  }
@@ -12,7 +12,7 @@
12
12
  import type { IterateResult } from "./types.mts";
13
13
  import type { ResolveResult } from "./comments/resolve.mts";
14
14
  export declare const EXIT: Readonly<{
15
- /** `cancel` + `merged` or `ready-delay-elapsed` — shepherd finished cleanly. */
15
+ /** Clean terminal stop, including a draft held by disabled automatic mark-ready. */
16
16
  readonly OK: 0;
17
17
  /** Nothing to do yet, or a clean PR whose ready-delay is still counting. */
18
18
  readonly WAIT: 10;
@@ -10,7 +10,7 @@
10
10
  * ran to completion and is reporting PR state. See docs/exit-codes.md.
11
11
  */
12
12
  export const EXIT = Object.freeze({
13
- /** `cancel` + `merged` or `ready-delay-elapsed` — shepherd finished cleanly. */
13
+ /** Clean terminal stop, including a draft held by disabled automatic mark-ready. */
14
14
  OK: 0,
15
15
  /** Nothing to do yet, or a clean PR whose ready-delay is still counting. */
16
16
  WAIT: 10,
@@ -55,6 +55,7 @@ export class ShepherdError extends Error {
55
55
  const CANCEL_REASON_EXIT_CODE = {
56
56
  merged: EXIT.OK,
57
57
  "ready-delay-elapsed": EXIT.OK,
58
+ "auto-mark-ready-disabled": EXIT.OK,
58
59
  closed: EXIT.CLOSED,
59
60
  };
60
61
  export function iterateResultToExitCode(result) {
@@ -28,7 +28,9 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
28
28
  let remainingSeconds;
29
29
  if (raw.isDraft &&
30
30
  blockingReviewerInProgress &&
31
- (action === "mark_ready" || reasons.includes("draft-auto-mark-ready-disabled"))) {
31
+ (action === "mark_ready" ||
32
+ reasons.includes("draft-auto-mark-ready-disabled") ||
33
+ reasons.includes("draft-readiness-verification-required"))) {
32
34
  action = "wait";
33
35
  reasons = ["blocking-reviewer-in-progress"];
34
36
  }
@@ -40,18 +40,17 @@ export function routePollSummary(raw, checks, review, opts) {
40
40
  }
41
41
  if (raw.isDraft) {
42
42
  const autoMarkReadyDisabled = opts.noAutoMarkReady || actions.autoMarkReady === false;
43
- // The stack selector asks the agent to mark a disabled draft ready, so
44
- // that transition needs the same capability as the automatic one.
45
- if (!canGenerateGithubMutation(raw.viewerCanUpdate, "ready") &&
46
- (!autoMarkReadyDisabled || opts.stackPrNumber !== undefined)) {
43
+ if (autoMarkReadyDisabled && (checks.incomplete || review.incomplete))
44
+ return { action: "fix_code", reasons: ["draft-readiness-verification-required"] };
45
+ if (autoMarkReadyDisabled)
46
+ return { action: "cancel", reasons: ["draft-auto-mark-ready-disabled"] };
47
+ if (!canGenerateGithubMutation(raw.viewerCanUpdate, "ready")) {
47
48
  const unsupported = getGithubTransport() === "rest" && raw.viewerCanUpdate !== false && !isCcrTransport();
48
49
  if (unsupported)
49
50
  return { action: "escalate", reasons: ["transport-unsupported"] };
50
51
  return { action: "escalate", reasons: ["mark-ready-authorization-required"] };
51
52
  }
52
- return autoMarkReadyDisabled
53
- ? { action: "wait", reasons: ["draft-auto-mark-ready-disabled"] }
54
- : { action: "mark_ready", reasons: ["draft-appears-ready"] };
53
+ return { action: "mark_ready", reasons: ["draft-appears-ready"] };
55
54
  }
56
55
  if (opts.merge && (raw.isInMergeQueue || raw.autoMergeRequest)) {
57
56
  return {
@@ -0,0 +1,6 @@
1
+ import type { scanMarkdownLines } from "./markdown-line.mts";
2
+ export declare const LEGACY: RegExp;
3
+ export declare const JOURNAL_SUMMARY: RegExp;
4
+ export declare const CLOSE = "</details>";
5
+ /** Return the line of the canonical `</details>` closing a journal container, or null. */
6
+ export declare function closeJournalContainer(lines: string[], syntax: ReturnType<typeof scanMarkdownLines>, start: number): number | null;
@@ -0,0 +1,33 @@
1
+ import { detailsTags, rawDetailsTags, rawHtmlState } from "./markdown-details.mjs";
2
+ export const LEGACY = /^ {0,3}##[ \t]+Shepherd[ \t]+Journal(?:[ \t]+#+)?[ \t]*$/;
3
+ export const JOURNAL_SUMMARY = /^<summary>\s*Shepherd\s+Journal\b/i;
4
+ export const CLOSE = "</details>";
5
+ /** Return the line of the canonical `</details>` closing a journal container, or null. */
6
+ export function closeJournalContainer(lines, syntax, start) {
7
+ let depth = 1;
8
+ let html = rawHtmlState();
9
+ for (let i = start; i < lines.length; i++) {
10
+ if (syntax[i].nested)
11
+ continue;
12
+ if (syntax[i].rawHtml) {
13
+ for (const tag of rawDetailsTags(lines[i], html))
14
+ if ((depth += tag) === 0)
15
+ return null;
16
+ continue;
17
+ }
18
+ html = rawHtmlState();
19
+ if (syntax[i].ignored)
20
+ continue;
21
+ const visible = syntax[i].visiblePrefix;
22
+ if (JOURNAL_SUMMARY.test(visible.trimStart()) || LEGACY.test(visible))
23
+ return null;
24
+ depth += detailsTags(visible, false);
25
+ const closes = detailsTags(visible, true);
26
+ if (closes) {
27
+ depth -= closes;
28
+ if (depth <= 0)
29
+ return depth === 0 && lines[i] === CLOSE ? i : null;
30
+ }
31
+ }
32
+ return null;
33
+ }
@@ -0,0 +1,15 @@
1
+ export declare function detailsTags(line: string, closing: boolean): number;
2
+ /** Tokenizer state carried across the lines of one raw HTML block. */
3
+ export type RawHtmlState = {
4
+ mode: "comment" | "data" | "raw-text" | "tag";
5
+ quote: '"' | "'" | null;
6
+ rawTag: string | null;
7
+ terminator: RegExp;
8
+ };
9
+ export declare const rawHtmlState: () => RawHtmlState;
10
+ /**
11
+ * Return `1` per `<details>` opener and `-1` per closer that the browser
12
+ * creates from a raw HTML block line. Tags inside comments, CDATA, raw-text
13
+ * elements, and (possibly multiline) attribute values do not count.
14
+ */
15
+ export declare function rawDetailsTags(line: string, state: RawHtmlState): Array<1 | -1>;
@@ -0,0 +1,92 @@
1
+ import { inQuotedHtmlAttribute } from "./markdown-html.mjs";
2
+ import { structuralDetailsStart } from "./markdown-structure.mjs";
3
+ export function detailsTags(line, closing) {
4
+ if (structuralDetailsStart(line) === null)
5
+ return 0;
6
+ const expression = closing ? /<\/details>/gi : /<details(?:\s+[^>]*)?\/?>/gi;
7
+ return [...line.matchAll(expression)].filter((match) => !inQuotedHtmlAttribute(line, match.index)).length;
8
+ }
9
+ const RAW_TEXT = new Set([
10
+ "iframe",
11
+ "noembed",
12
+ "noframes",
13
+ "script",
14
+ "style",
15
+ "textarea",
16
+ "title",
17
+ "xmp",
18
+ ]);
19
+ export const rawHtmlState = () => ({
20
+ mode: "data",
21
+ quote: null,
22
+ rawTag: null,
23
+ terminator: /--!?>/,
24
+ });
25
+ // Comments, CDATA sections, and bogus comments such as `<!x>`, `<?x>`, and `</ x>`.
26
+ const COMMENTS = [
27
+ [/^<!--/, /--!?>/],
28
+ [/^<!\[CDATA\[/, /\]\]>/],
29
+ [/^<(?:[!?]|\/[^A-Za-z>])/, />/],
30
+ ];
31
+ /**
32
+ * Return `1` per `<details>` opener and `-1` per closer that the browser
33
+ * creates from a raw HTML block line. Tags inside comments, CDATA, raw-text
34
+ * elements, and (possibly multiline) attribute values do not count.
35
+ */
36
+ export function rawDetailsTags(line, state) {
37
+ const tags = [];
38
+ let i = 0;
39
+ while (i < line.length) {
40
+ if (state.mode === "comment") {
41
+ const end = state.terminator.exec(line.slice(i));
42
+ if (!end)
43
+ break;
44
+ state.mode = "data";
45
+ i += end.index + end[0].length;
46
+ }
47
+ else if (state.mode === "raw-text") {
48
+ const end = new RegExp(`</${state.rawTag}(?=[\\s/>]|$)`, "i").exec(line.slice(i));
49
+ if (!end)
50
+ break;
51
+ state.mode = "tag";
52
+ state.rawTag = null;
53
+ i += end.index + end[0].length;
54
+ }
55
+ else if (state.mode === "tag") {
56
+ const char = line[i++];
57
+ if (state.quote) {
58
+ if (char === state.quote)
59
+ state.quote = null;
60
+ }
61
+ else if (char === '"' || char === "'")
62
+ state.quote = char;
63
+ else if (char === ">")
64
+ state.mode = state.rawTag ? "raw-text" : "data";
65
+ }
66
+ else {
67
+ const open = line.indexOf("<", i);
68
+ if (open === -1)
69
+ break;
70
+ const comment = COMMENTS.map(([start, end]) => [start.exec(line.slice(open)), end]).find(([start]) => start);
71
+ if (comment) {
72
+ state.mode = "comment";
73
+ state.terminator = comment[1];
74
+ i = open + comment[0][0].length;
75
+ continue;
76
+ }
77
+ // Tag names run until whitespace, `/`, or `>`, so `details:x` is not `details`.
78
+ const tag = /^<(\/?)([A-Za-z][^\s/>]*)/.exec(line.slice(open));
79
+ if (!tag) {
80
+ i = open + 1;
81
+ continue;
82
+ }
83
+ const name = tag[2].toLowerCase();
84
+ if (name === "details")
85
+ tags.push(tag[1] ? -1 : 1);
86
+ state.mode = "tag";
87
+ state.rawTag = !tag[1] && RAW_TEXT.has(name) ? name : null;
88
+ i = open + tag[0].length;
89
+ }
90
+ }
91
+ return tags;
92
+ }
@@ -1,6 +1,8 @@
1
+ /** `rawHtml` marks lines inside a blank-line-terminated raw HTML block, whose tags still render. */
1
2
  type MarkdownLine = {
2
3
  ignored: boolean;
3
4
  nested: boolean;
5
+ rawHtml: boolean;
4
6
  visiblePrefix: string;
5
7
  };
6
8
  /** Return whether a Markdown line starts a block that interrupts inline content. */
@@ -35,7 +35,7 @@ function scanMarkdown(lines) {
35
35
  const container = resolveMarkdownContainer(line, activeContainer);
36
36
  activeContainer = container.tokens;
37
37
  const nested = container.tokens.length > 0;
38
- const push = (ignored, visiblePrefix) => result.push({ ignored, nested, visiblePrefix });
38
+ const push = (ignored, visiblePrefix, rawHtml = false) => result.push({ ignored, nested, rawHtml, visiblePrefix });
39
39
  if (indentedCodeContainer) {
40
40
  if (stripMarkdownContainer(line, indentedCodeContainer) !== null) {
41
41
  push(true, "");
@@ -53,7 +53,7 @@ function scanMarkdown(lines) {
53
53
  else {
54
54
  const end = rawHtmlEnd(html, content);
55
55
  if (end === null) {
56
- push(true, "");
56
+ push(true, "", html.kind === "blank-line");
57
57
  continue;
58
58
  }
59
59
  html = null;
@@ -100,7 +100,7 @@ function scanMarkdown(lines) {
100
100
  const end = rawHtmlEnd(openingHtml, content);
101
101
  if (end === null) {
102
102
  html = openingHtml;
103
- push(true, "");
103
+ push(true, "", openingHtml.kind === "blank-line");
104
104
  continue;
105
105
  }
106
106
  allowCodeOpeners = false;
@@ -1,42 +1,16 @@
1
- import { inQuotedHtmlAttribute } from "./markdown-html.mjs";
1
+ import { detailsTags, rawDetailsTags, rawHtmlState } from "./markdown-details.mjs";
2
+ import { CLOSE, closeJournalContainer, JOURNAL_SUMMARY, LEGACY } from "./journal-container.mjs";
2
3
  import { isSafeMarkdownInsertionPoint, scanMarkdownLines } from "./markdown-line.mjs";
3
4
  import { setextParagraphStart } from "./markdown-setext.mjs";
4
- import { structuralDetailsStart } from "./markdown-structure.mjs";
5
- const LEGACY = /^ {0,3}##[ \t]+Shepherd[ \t]+Journal(?:[ \t]+#+)?[ \t]*$/;
6
- const JOURNAL_SUMMARY = /^<summary>\s*Shepherd\s+Journal\b/i;
7
5
  const SETEXT = /^ {0,3}(?:=+|-+)[ \t]*$/;
8
- const CLOSE = "</details>";
9
6
  const OPEN = "<details>";
10
7
  const SUMMARY = "<summary>Shepherd Journal</summary>";
11
8
  const stripCr = (s) => s.replace(/\r$/, "");
12
- function detailsTags(line, closing) {
13
- if (structuralDetailsStart(line) === null)
14
- return 0;
15
- const expression = closing ? /<\/details>/gi : /<details(?:\s+[^>]*)?\/?>/gi;
16
- return [...line.matchAll(expression)].filter((match) => !inQuotedHtmlAttribute(line, match.index)).length;
17
- }
18
- function close(lines, syntax, start) {
19
- let depth = 1;
20
- for (let i = start; i < lines.length; i++) {
21
- if (syntax[i].ignored || syntax[i].nested)
22
- continue;
23
- const visible = syntax[i].visiblePrefix;
24
- if (JOURNAL_SUMMARY.test(visible.trimStart()) || LEGACY.test(visible))
25
- return null;
26
- depth += detailsTags(visible, false);
27
- const closes = detailsTags(visible, true);
28
- if (closes) {
29
- depth -= closes;
30
- if (depth <= 0)
31
- return depth === 0 && lines[i] === CLOSE ? i : null;
32
- }
33
- }
34
- return null;
35
- }
36
9
  export function scanShepherdJournal(lines) {
37
10
  const syntax = scanMarkdownLines(lines);
38
11
  const found = [];
39
12
  let detailsDepth = 0;
13
+ let html = rawHtmlState();
40
14
  let legacy = null;
41
15
  for (let i = 0; i < lines.length; i++) {
42
16
  if (!syntax[i].ignored &&
@@ -48,7 +22,16 @@ export function scanShepherdJournal(lines) {
48
22
  legacy.contentEnd = legacy.end = i;
49
23
  continue;
50
24
  }
51
- if (syntax[i].ignored || syntax[i].nested)
25
+ if (syntax[i].nested)
26
+ continue;
27
+ if (syntax[i].rawHtml) {
28
+ // Raw-HTML details stay open past the block; stray closers are ignored, as in the browser.
29
+ for (const tag of rawDetailsTags(lines[i], html))
30
+ detailsDepth = Math.max(0, detailsDepth + tag);
31
+ continue;
32
+ }
33
+ html = rawHtmlState();
34
+ if (syntax[i].ignored)
52
35
  continue;
53
36
  const visible = syntax[i].visiblePrefix;
54
37
  detailsDepth += detailsTags(visible, false);
@@ -66,7 +49,7 @@ export function scanShepherdJournal(lines) {
66
49
  lines[i - 1] !== OPEN ||
67
50
  lines[i + 1] !== "")
68
51
  return "error";
69
- const end = close(lines, syntax, i + 2);
52
+ const end = closeJournalContainer(lines, syntax, i + 2);
70
53
  if (end === null)
71
54
  return "error";
72
55
  detailsDepth--;
@@ -1,7 +1,7 @@
1
1
  import type { AgentThread, AgentComment, AgentCheck, GlobalOptions, RelevantCheck, ShepherdStatus, FirstLookThread, FirstLookComment, RuleAutoResolveReport } from "./report.mts";
2
2
  import type { ActiveCheck, PrActivitySummary } from "./activity.mts";
3
3
  import type { BranchProtection, MergeStateStatus, MergedBasePullRequest, Review, ReviewDecision, ReviewThread, ShepherdMergeStatus } from "./github.mts";
4
- import type { MergeRequirements, StackDraftHold } from "./merge-requirements.mts";
4
+ import type { MergeRequirements } from "./merge-requirements.mts";
5
5
  import type { EscalateDetails } from "./escalate.mts";
6
6
  import type { MergeCommandPlan } from "./merge-action.mts";
7
7
  import type { ProtectedRun } from "./protected-run.mts";
@@ -75,14 +75,13 @@ interface IterateResultWait extends IterateResultBase {
75
75
  action: "wait";
76
76
  log: string;
77
77
  deferredWork?: import("./merge-queue.mts").IterateDeferredWork;
78
- stackDraftHold?: StackDraftHold;
79
78
  }
80
79
  /** Clean PR whose ready-delay is still counting. `--until-terminal` returns this instead of sleeping. */
81
80
  interface IterateResultReady extends IterateResultBase {
82
81
  action: "ready";
83
82
  log: string;
84
83
  }
85
- export type CancelReason = "merged" | "closed" | "ready-delay-elapsed";
84
+ export type CancelReason = "merged" | "closed" | "ready-delay-elapsed" | "auto-mark-ready-disabled";
86
85
  interface IterateResultCancel extends IterateResultBase {
87
86
  action: "cancel";
88
87
  reason: CancelReason;