pr-shepherd 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.39.0",
4
+ "version": "0.40.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -1,4 +1,53 @@
1
- import { isJournalLikeSummary, isReservedJournalMarker } from "./journal-markdown.mjs";
1
+ import { inQuotedHtmlAttribute, rawHtmlEnd, rawHtmlStart } from "../../journal/markdown-html.mjs";
2
+ import { stripMarkdownContainer } from "../../journal/markdown-container.mjs";
3
+ import { scanMarkdownLines } from "../../journal/markdown-line.mjs";
4
+ const RESERVED_CONTAINER_TAG = /(?:<details(?:\s+[^>]*)?\/?>|<\/details>)|<summary(?:\s+[^>]*)?>\s*Shepherd\s+Journal\b/i;
5
+ function reservedContainerTag(lines) {
6
+ const syntax = scanMarkdownLines(lines);
7
+ for (const [index] of lines.entries()) {
8
+ const match = RESERVED_CONTAINER_TAG.exec(syntax[index].visiblePrefix);
9
+ if (match)
10
+ return match[0];
11
+ }
12
+ return reservedContainerTagInRawHtml(lines);
13
+ }
14
+ function reservedContainerTagInRawHtml(lines) {
15
+ let block = null;
16
+ for (const line of lines) {
17
+ if (block) {
18
+ const content = stripMarkdownContainer(line, block.container);
19
+ if (content === null) {
20
+ block = null;
21
+ }
22
+ else {
23
+ const marker = reservedContainerTagOutsideHtmlAttributes(content);
24
+ if (marker)
25
+ return marker;
26
+ if (rawHtmlEnd(block, content) !== null)
27
+ block = null;
28
+ continue;
29
+ }
30
+ }
31
+ const opening = rawHtmlStart(line);
32
+ if (!opening)
33
+ continue;
34
+ const content = stripMarkdownContainer(line, opening.container) ?? "";
35
+ const marker = reservedContainerTagOutsideHtmlAttributes(content);
36
+ if (marker)
37
+ return marker;
38
+ if (rawHtmlEnd(opening, content) === null)
39
+ block = opening;
40
+ }
41
+ return null;
42
+ }
43
+ function reservedContainerTagOutsideHtmlAttributes(line) {
44
+ const expression = new RegExp(RESERVED_CONTAINER_TAG.source, "gi");
45
+ for (const match of line.matchAll(expression)) {
46
+ if (!inQuotedHtmlAttribute(line, match.index))
47
+ return match[0];
48
+ }
49
+ return null;
50
+ }
2
51
  export function validateJournalItem(input) {
3
52
  const lines = input.split("\n").map((line) => line.trimEnd());
4
53
  const nonBlank = lines.filter((line) => line.trim() !== "");
@@ -11,6 +60,13 @@ export function validateJournalItem(input) {
11
60
  error: `journal item must start with "- <text>"; got: ${JSON.stringify(nonBlank[0].slice(0, 40))}`,
12
61
  };
13
62
  }
63
+ const marker = reservedContainerTag(lines);
64
+ if (marker) {
65
+ return {
66
+ ok: false,
67
+ error: `journal item must not contain journal container marker ${JSON.stringify(marker)}`,
68
+ };
69
+ }
14
70
  for (const line of nonBlank.slice(1)) {
15
71
  if (line.startsWith("#")) {
16
72
  return {
@@ -18,12 +74,6 @@ export function validateJournalItem(input) {
18
74
  error: "journal item lines must not start with # (would break section structure)",
19
75
  };
20
76
  }
21
- if (isReservedJournalMarker(line.trim()) || isJournalLikeSummary(line.trim())) {
22
- return {
23
- ok: false,
24
- error: `journal item must not contain standalone journal container marker ${JSON.stringify(line.trim())}`,
25
- };
26
- }
27
77
  }
28
78
  return { ok: true, item: lines.join("\n").trim() };
29
79
  }
@@ -8,6 +8,5 @@ export type MarkdownScanState = {
8
8
  };
9
9
  export declare function findDetailsClose(lines: string[], startIdx: number): number;
10
10
  export declare function isJournalLikeSummary(line: string): boolean;
11
- export declare function isReservedJournalMarker(line: string): boolean;
12
11
  export declare function skipMarkdownLine(state: MarkdownScanState, line: string): boolean;
13
12
  export {};
@@ -1,4 +1,4 @@
1
- import { SHEPHERD_JOURNAL_DETAILS_CLOSE, SHEPHERD_JOURNAL_DETAILS_SUMMARY, } from "../shepherd-journal.mjs";
1
+ import { SHEPHERD_JOURNAL_DETAILS_CLOSE } from "../shepherd-journal.mjs";
2
2
  export function findDetailsClose(lines, startIdx) {
3
3
  let depth = 1;
4
4
  const state = { fence: null, comment: false };
@@ -23,11 +23,6 @@ export function findDetailsClose(lines, startIdx) {
23
23
  export function isJournalLikeSummary(line) {
24
24
  return /^<summary>\s*Shepherd\s+Journal\b/i.test(line);
25
25
  }
26
- export function isReservedJournalMarker(line) {
27
- return (isDetailsOpening(line) ||
28
- line === SHEPHERD_JOURNAL_DETAILS_SUMMARY ||
29
- line === SHEPHERD_JOURNAL_DETAILS_CLOSE);
30
- }
31
26
  function isDetailsOpening(line) {
32
27
  return /^<details(?:\s+[^>]*)?>$/.test(line);
33
28
  }
@@ -0,0 +1,8 @@
1
+ import { validateJournalItem } from "../commands/journal/journal-item.mts";
2
+ export { validateJournalItem };
3
+ export interface AppendResult {
4
+ body: string;
5
+ mutated: boolean;
6
+ sectionExisted: boolean;
7
+ }
8
+ export declare function appendJournalItem(body: string, item: string): AppendResult;
@@ -0,0 +1,96 @@
1
+ import { validateJournalItem } from "../commands/journal/journal-item.mjs";
2
+ import { fenceStart } from "./markdown-container.mjs";
3
+ import { rawHtmlStart } from "./markdown-html.mjs";
4
+ import { containsJournalEntry, scanShepherdJournal } from "./reconcile.mjs";
5
+ import { isSafeMarkdownInsertionPoint } from "./markdown-line.mjs";
6
+ const OPEN = "<details>";
7
+ const SUMMARY = "<summary>Shepherd Journal</summary>";
8
+ const CLOSE = "</details>";
9
+ export { validateJournalItem };
10
+ export function appendJournalItem(body, item) {
11
+ const validated = validateJournalItem(item);
12
+ if (!validated.ok)
13
+ throw new Error(validated.error);
14
+ if (validated.item
15
+ .split("\n")
16
+ .slice(1)
17
+ .some((line) => /^(?:[-+*]|\d{1,9}[.)])[ \t]+/.test(line)))
18
+ throw new Error("journal item must contain exactly one top-level list item");
19
+ item = validated.item;
20
+ if (item
21
+ .split("\n")
22
+ .some((line, index) => fenceStart(index === 0 ? line.slice(2) : line) ||
23
+ rawHtmlStart(index === 0 ? line.slice(2) : line)))
24
+ throw new Error("journal item must not start with a fenced or raw HTML block");
25
+ const newline = body.includes("\r\n") ? "\r\n" : "\n";
26
+ const lines = body.replaceAll("\r\n", "\n").split("\n");
27
+ const bounds = scanShepherdJournal(lines);
28
+ if (bounds === "error")
29
+ throw new Error("malformed, duplicate, or ambiguous Shepherd Journal container");
30
+ if (!bounds) {
31
+ if (!isSafeMarkdownInsertionPoint(lines))
32
+ throw new Error("cannot append Shepherd Journal inside an unterminated Markdown construct");
33
+ return create(lines, item, newline);
34
+ }
35
+ const content = lines.slice(bounds.contentStart, bounds.contentEnd);
36
+ if (bounds.format === "details" && containsJournalEntry(content, item))
37
+ return { body, mutated: false, sectionExisted: true };
38
+ const next = [...trimEnd(content), ...item.split("\n")];
39
+ if (bounds.format === "details") {
40
+ return {
41
+ body: [
42
+ ...lines.slice(0, bounds.contentStart),
43
+ ...next,
44
+ ...lines.slice(bounds.contentEnd),
45
+ ].join(newline),
46
+ mutated: true,
47
+ sectionExisted: true,
48
+ };
49
+ }
50
+ const legacyContent = trim(content);
51
+ const canonical = [
52
+ OPEN,
53
+ SUMMARY,
54
+ "",
55
+ ...legacyContent,
56
+ ...(containsJournalEntry(content, item) ? [] : item.split("\n")),
57
+ CLOSE,
58
+ ];
59
+ const suffix = lines.slice(bounds.end);
60
+ return {
61
+ body: [
62
+ ...lines.slice(0, bounds.start),
63
+ ...canonical,
64
+ ...(suffix[0]?.trim() ? [""] : []),
65
+ ...suffix,
66
+ ].join(newline),
67
+ mutated: true,
68
+ sectionExisted: true,
69
+ };
70
+ }
71
+ function create(lines, item, newline) {
72
+ const existing = trimEnd(lines);
73
+ return {
74
+ body: [
75
+ ...existing,
76
+ ...(existing.length ? [""] : []),
77
+ OPEN,
78
+ SUMMARY,
79
+ "",
80
+ ...item.split("\n"),
81
+ CLOSE,
82
+ ].join(newline),
83
+ mutated: true,
84
+ sectionExisted: false,
85
+ };
86
+ }
87
+ function trimEnd(lines) {
88
+ let end = lines.length;
89
+ while (end > 0 && lines[end - 1].trim() === "")
90
+ end--;
91
+ return lines.slice(0, end);
92
+ }
93
+ function trim(lines) {
94
+ const start = lines.findIndex((line) => line.trim() !== "");
95
+ return start === -1 ? [] : trimEnd(lines.slice(start));
96
+ }
@@ -0,0 +1,3 @@
1
+ /** GitHub-free Shepherd Journal helpers for programmatic PR-body reconciliation. */
2
+ export { appendJournalItem, validateJournalItem, type AppendResult } from "./append.mts";
3
+ export { reconcileShepherdJournal, type ShepherdJournalReconcileResult } from "./reconcile.mts";
@@ -0,0 +1,3 @@
1
+ /** GitHub-free Shepherd Journal helpers for programmatic PR-body reconciliation. */
2
+ export { appendJournalItem, validateJournalItem } from "./append.mjs";
3
+ export { reconcileShepherdJournal } from "./reconcile.mjs";
@@ -0,0 +1,9 @@
1
+ export type BacktickRun = {
2
+ escaped: boolean;
3
+ index: number;
4
+ length: number;
5
+ quoted: boolean;
6
+ };
7
+ export declare function backtickRuns(line: string): BacktickRun[];
8
+ export declare function nextBacktickRun(runs: BacktickRun[], offset: number, length?: number): BacktickRun | undefined;
9
+ export declare function nextCodeOpener(runs: BacktickRun[], offset: number): BacktickRun | undefined;
@@ -0,0 +1,34 @@
1
+ export function backtickRuns(line) {
2
+ const runs = [];
3
+ let inTag = false;
4
+ let quote = null;
5
+ for (let i = 0; i < line.length; i++) {
6
+ if (!inTag && line[i] === "<" && /^\/?[A-Za-z]/.test(line.slice(i + 1)))
7
+ inTag = true;
8
+ else if (inTag && quote) {
9
+ if (line[i] === quote)
10
+ quote = null;
11
+ }
12
+ else if (inTag && (line[i] === '"' || line[i] === "'"))
13
+ quote = line[i] === '"' ? '"' : "'";
14
+ else if (inTag && line[i] === ">")
15
+ inTag = false;
16
+ if (line[i] !== "`")
17
+ continue;
18
+ let slashes = 0;
19
+ while (line[i - slashes - 1] === "\\")
20
+ slashes++;
21
+ let end = i;
22
+ while (line[end] === "`")
23
+ end++;
24
+ runs.push({ escaped: slashes % 2 === 1, index: i, length: end - i, quoted: quote !== null });
25
+ i = end - 1;
26
+ }
27
+ return runs;
28
+ }
29
+ export function nextBacktickRun(runs, offset, length) {
30
+ return runs.find((run) => run.index >= offset && (length === undefined || run.length === length));
31
+ }
32
+ export function nextCodeOpener(runs, offset) {
33
+ return runs.find((run) => run.index >= offset && !run.quoted);
34
+ }
@@ -0,0 +1,22 @@
1
+ export type MarkdownContainer = Array<{
2
+ kind: "list";
3
+ width: number;
4
+ } | {
5
+ kind: "quote";
6
+ }>;
7
+ export declare function markdownContainer(line: string): {
8
+ content: string;
9
+ indent: number;
10
+ tokens: MarkdownContainer;
11
+ };
12
+ export declare function resolveMarkdownContainer(line: string, active: MarkdownContainer): ReturnType<typeof markdownContainer>;
13
+ export declare function fenceStart(line: string, parsed?: {
14
+ content: string;
15
+ indent: number;
16
+ tokens: MarkdownContainer;
17
+ }): {
18
+ container: MarkdownContainer;
19
+ length: number;
20
+ marker: string;
21
+ } | null;
22
+ export declare function stripMarkdownContainer(line: string, tokens: MarkdownContainer): string | null;
@@ -0,0 +1,110 @@
1
+ function visualColumn(line, end) {
2
+ let column = 0;
3
+ for (let i = 0; i < end; i++)
4
+ column = line[i] === "\t" ? column + (4 - (column % 4)) : column + 1;
5
+ return column;
6
+ }
7
+ function quoteEnd(line, offset) {
8
+ let marker = offset;
9
+ while (marker < offset + 3 && line[marker] === " ")
10
+ marker++;
11
+ if (line[marker] !== ">")
12
+ return null;
13
+ marker++;
14
+ return line[marker] === " " || line[marker] === "\t" ? marker + 1 : marker;
15
+ }
16
+ function listEnd(line, offset) {
17
+ let marker = offset;
18
+ while (marker < offset + 3 && line[marker] === " ")
19
+ marker++;
20
+ const match = line.slice(marker).match(/^(?:[-+*]|\d{1,9}[.)])/)?.[0];
21
+ if (!match)
22
+ return null;
23
+ let end = marker + match.length;
24
+ const paddingStart = end;
25
+ const startColumn = visualColumn(line, offset);
26
+ let column = visualColumn(line, end);
27
+ let padding = 0;
28
+ while (line[end] === " " || line[end] === "\t") {
29
+ const nextColumn = line[end] === "\t" ? column + (4 - (column % 4)) : column + 1;
30
+ padding += nextColumn - column;
31
+ column = nextColumn;
32
+ end++;
33
+ }
34
+ if (padding === 0)
35
+ return null;
36
+ if (padding > 4) {
37
+ end = paddingStart + 1;
38
+ const actualWidth = visualColumn(line, end) - visualColumn(line, paddingStart);
39
+ column = visualColumn(line, paddingStart) + 1;
40
+ return { end, indent: actualWidth - 1, width: column - startColumn };
41
+ }
42
+ return { end, indent: 0, width: column - startColumn };
43
+ }
44
+ export function markdownContainer(line) {
45
+ const tokens = [];
46
+ let indent = 0;
47
+ let offset = 0;
48
+ while (true) {
49
+ const quote = quoteEnd(line, offset);
50
+ if (quote !== null) {
51
+ tokens.push({ kind: "quote" });
52
+ offset = quote;
53
+ continue;
54
+ }
55
+ const list = listEnd(line, offset);
56
+ if (!list)
57
+ break;
58
+ tokens.push({ kind: "list", width: list.width });
59
+ offset = list.end;
60
+ indent = list.indent;
61
+ if (indent)
62
+ break;
63
+ }
64
+ return { content: line.slice(offset), indent, tokens };
65
+ }
66
+ export function resolveMarkdownContainer(line, active) {
67
+ for (let depth = active.length; depth > 0; depth--) {
68
+ const retained = active.slice(0, depth);
69
+ const continuation = stripMarkdownContainer(line, retained);
70
+ if (continuation === null)
71
+ continue;
72
+ const nested = markdownContainer(continuation);
73
+ return {
74
+ content: nested.content,
75
+ indent: nested.indent,
76
+ tokens: [...retained, ...nested.tokens],
77
+ };
78
+ }
79
+ return markdownContainer(line);
80
+ }
81
+ export function fenceStart(line, parsed = markdownContainer(line)) {
82
+ const match = `${" ".repeat(parsed.indent)}${parsed.content}`.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
83
+ return match && !(match[1][0] === "`" && match[2].includes("`"))
84
+ ? { container: parsed.tokens, length: match[1].length, marker: match[1][0] }
85
+ : null;
86
+ }
87
+ export function stripMarkdownContainer(line, tokens) {
88
+ let offset = 0;
89
+ for (const [index, token] of tokens.entries()) {
90
+ if (token.kind === "quote") {
91
+ const end = quoteEnd(line, offset);
92
+ if (end === null)
93
+ return null;
94
+ offset = end;
95
+ continue;
96
+ }
97
+ if (line.slice(offset).trim() === "" &&
98
+ tokens.slice(index).every((item) => item.kind === "list"))
99
+ return "";
100
+ const startColumn = visualColumn(line, offset);
101
+ let column = startColumn;
102
+ while (column - startColumn < token.width && (line[offset] === " " || line[offset] === "\t")) {
103
+ column = line[offset] === "\t" ? column + (4 - (column % 4)) : column + 1;
104
+ offset++;
105
+ }
106
+ if (column - startColumn < token.width)
107
+ return null;
108
+ }
109
+ return line.slice(offset);
110
+ }
@@ -0,0 +1,20 @@
1
+ import { type MarkdownContainer } from "./markdown-container.mts";
2
+ export type RawHtmlBlock = {
3
+ container: MarkdownContainer;
4
+ kind: "closing-tag";
5
+ tag: string;
6
+ } | {
7
+ container: MarkdownContainer;
8
+ kind: "blank-line";
9
+ } | {
10
+ container: MarkdownContainer;
11
+ kind: "terminator";
12
+ terminator: string;
13
+ };
14
+ export declare function rawHtmlStart(line: string, parsed?: {
15
+ content: string;
16
+ indent: number;
17
+ tokens: MarkdownContainer;
18
+ }): RawHtmlBlock | null;
19
+ export declare function rawHtmlEnd(block: RawHtmlBlock, content: string): number | null;
20
+ export declare function inQuotedHtmlAttribute(line: string, offset: number): boolean;
@@ -0,0 +1,119 @@
1
+ import { markdownContainer } from "./markdown-container.mjs";
2
+ const BLOCK_TAGS = new Set([
3
+ "address",
4
+ "article",
5
+ "aside",
6
+ "base",
7
+ "basefont",
8
+ "blockquote",
9
+ "body",
10
+ "caption",
11
+ "center",
12
+ "col",
13
+ "colgroup",
14
+ "dd",
15
+ "dialog",
16
+ "dir",
17
+ "div",
18
+ "dl",
19
+ "dt",
20
+ "fieldset",
21
+ "figcaption",
22
+ "figure",
23
+ "footer",
24
+ "form",
25
+ "frame",
26
+ "frameset",
27
+ "h1",
28
+ "h2",
29
+ "h3",
30
+ "h4",
31
+ "h5",
32
+ "h6",
33
+ "head",
34
+ "header",
35
+ "hr",
36
+ "html",
37
+ "iframe",
38
+ "legend",
39
+ "li",
40
+ "link",
41
+ "main",
42
+ "menu",
43
+ "menuitem",
44
+ "nav",
45
+ "noframes",
46
+ "ol",
47
+ "optgroup",
48
+ "option",
49
+ "p",
50
+ "param",
51
+ "plaintext",
52
+ "search",
53
+ "section",
54
+ "table",
55
+ "tbody",
56
+ "td",
57
+ "tfoot",
58
+ "th",
59
+ "thead",
60
+ "title",
61
+ "tr",
62
+ "track",
63
+ "ul",
64
+ "wbr",
65
+ ]);
66
+ export function rawHtmlStart(line, parsed = markdownContainer(line)) {
67
+ const { content, indent, tokens } = parsed;
68
+ const visible = `${" ".repeat(indent)}${content}`;
69
+ const tag = visible.match(/^ {0,3}<(pre|script|style|textarea)(?:\s|>|$)/i)?.[1];
70
+ if (tag)
71
+ return { container: tokens, kind: "closing-tag", tag };
72
+ if (/^ {0,3}<\?(?:.|\n)*/.test(visible))
73
+ return { container: tokens, kind: "terminator", terminator: "?>" };
74
+ if (/^ {0,3}<!\[CDATA\[/.test(visible))
75
+ return { container: tokens, kind: "terminator", terminator: "]]>" };
76
+ if (/^ {0,3}<![A-Z]/.test(visible))
77
+ return { container: tokens, kind: "terminator", terminator: ">" };
78
+ const block = visible
79
+ .match(/^ {0,3}<\/?([A-Za-z][A-Za-z0-9-]*)(?:\s|\/?>|$)/)?.[1]
80
+ ?.toLowerCase();
81
+ if (!block || block === "details" || block === "summary")
82
+ return null;
83
+ if (!BLOCK_TAGS.has(block) &&
84
+ !/^ {0,3}<\/?[A-Za-z][A-Za-z0-9-]*(?:\s[^<>]*?|\/?)>$/.test(visible))
85
+ return null;
86
+ return { container: tokens, kind: "blank-line" };
87
+ }
88
+ export function rawHtmlEnd(block, content) {
89
+ if (block.kind === "blank-line")
90
+ return content.trim() === "" ? 0 : null;
91
+ if (block.kind === "terminator") {
92
+ const index = content.indexOf(block.terminator);
93
+ return index === -1 ? null : index + block.terminator.length;
94
+ }
95
+ const close = new RegExp(`</${block.tag}>`, "i").exec(content);
96
+ return close ? close.index + close[0].length : null;
97
+ }
98
+ export function inQuotedHtmlAttribute(line, offset) {
99
+ let inTag = false;
100
+ let quote = null;
101
+ for (let i = 0; i < offset; i++) {
102
+ const char = line[i];
103
+ if (!inTag) {
104
+ if (char === "<" && /^\/?[A-Za-z]/.test(line.slice(i + 1)))
105
+ inTag = true;
106
+ continue;
107
+ }
108
+ if (quote) {
109
+ if (char === quote)
110
+ quote = null;
111
+ continue;
112
+ }
113
+ if (char === '"' || char === "'")
114
+ quote = char;
115
+ else if (char === ">")
116
+ inTag = false;
117
+ }
118
+ return inTag && quote !== null;
119
+ }
@@ -0,0 +1,8 @@
1
+ type MarkdownLine = {
2
+ ignored: boolean;
3
+ nested: boolean;
4
+ visiblePrefix: string;
5
+ };
6
+ export declare const scanMarkdownLines: (lines: string[]) => MarkdownLine[];
7
+ export declare const isSafeMarkdownInsertionPoint: (lines: string[]) => boolean;
8
+ export {};
@@ -0,0 +1,166 @@
1
+ import { fenceStart, resolveMarkdownContainer, stripMarkdownContainer, } from "./markdown-container.mjs";
2
+ import { inQuotedHtmlAttribute, rawHtmlEnd, rawHtmlStart, } from "./markdown-html.mjs";
3
+ import { backtickRuns, nextBacktickRun, nextCodeOpener } from "./markdown-backticks.mjs";
4
+ import { isIndentedCode, structuralDetailsStart } from "./markdown-structure.mjs";
5
+ function interruptsInlineContent(line) {
6
+ return (line.trim() === "" ||
7
+ /^ {0,3}(?:#{1,6}(?:[ \t]+|$)|>)/.test(line) ||
8
+ /^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+/.test(line) ||
9
+ /^ {0,3}(?:(?:\*[ \t]*){3,}|-+[ \t]*|(?:_[ \t]*){3,}|=+[ \t]*)$/.test(line) ||
10
+ fenceStart(line) !== null ||
11
+ rawHtmlStart(line) !== null ||
12
+ structuralDetailsStart(line) !== null);
13
+ }
14
+ function hasCloser(lines, lineIndex, offset, length) {
15
+ for (let i = lineIndex; i < lines.length; i++) {
16
+ if (i > lineIndex && interruptsInlineContent(lines[i]))
17
+ return false;
18
+ if (backtickRuns(lines[i]).some((run) => run.length === length && (i > lineIndex || run.index > offset)))
19
+ return true;
20
+ }
21
+ return false;
22
+ }
23
+ function scanMarkdown(lines) {
24
+ const result = [];
25
+ let codeSpan = null;
26
+ let comment = null;
27
+ let fence = null;
28
+ let html = null;
29
+ let activeContainer = [];
30
+ let indentedCodeContainer = null;
31
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
32
+ const line = lines[lineIndex];
33
+ const container = resolveMarkdownContainer(line, activeContainer);
34
+ activeContainer = container.tokens;
35
+ const nested = container.tokens.length > 0;
36
+ const push = (ignored, visiblePrefix) => result.push({ ignored, nested, visiblePrefix });
37
+ if (indentedCodeContainer) {
38
+ if (stripMarkdownContainer(line, indentedCodeContainer) !== null) {
39
+ push(true, "");
40
+ continue;
41
+ }
42
+ indentedCodeContainer = null;
43
+ }
44
+ let allowCodeOpeners = true;
45
+ let forceIgnored = false;
46
+ let scanOffset = 0;
47
+ if (html) {
48
+ const content = stripMarkdownContainer(line, html.container);
49
+ if (content === null)
50
+ html = null;
51
+ else {
52
+ const end = rawHtmlEnd(html, content);
53
+ if (end === null) {
54
+ push(true, "");
55
+ continue;
56
+ }
57
+ html = null;
58
+ allowCodeOpeners = false;
59
+ forceIgnored = true;
60
+ scanOffset = line.length - content.length + end;
61
+ }
62
+ }
63
+ if (!forceIgnored && fence) {
64
+ const content = stripMarkdownContainer(line, fence.container);
65
+ if (content === null)
66
+ fence = null;
67
+ else {
68
+ const match = content.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
69
+ if (match &&
70
+ match[1][0] === fence.marker &&
71
+ match[1].length >= fence.length &&
72
+ /^[ \t]*$/.test(match[2]))
73
+ fence = null;
74
+ push(true, "");
75
+ continue;
76
+ }
77
+ }
78
+ if (comment &&
79
+ (stripMarkdownContainer(line, comment.container) === null ||
80
+ (comment.inline && line.trim() === "")))
81
+ comment = null;
82
+ if (!forceIgnored && codeSpan === null && !comment) {
83
+ if (isIndentedCode(`${" ".repeat(container.indent)}${container.content}`)) {
84
+ if (container.tokens.length)
85
+ indentedCodeContainer = container.tokens;
86
+ push(true, "");
87
+ continue;
88
+ }
89
+ const openingFence = fenceStart(line, container);
90
+ if (openingFence) {
91
+ fence = openingFence;
92
+ push(true, "");
93
+ continue;
94
+ }
95
+ const openingHtml = rawHtmlStart(line, container);
96
+ if (openingHtml) {
97
+ const content = stripMarkdownContainer(line, openingHtml.container) ?? "";
98
+ const end = rawHtmlEnd(openingHtml, content);
99
+ if (end === null) {
100
+ html = openingHtml;
101
+ push(true, "");
102
+ continue;
103
+ }
104
+ allowCodeOpeners = false;
105
+ forceIgnored = true;
106
+ scanOffset = line.length - content.length + end;
107
+ }
108
+ }
109
+ const startsMasked = forceIgnored || codeSpan !== null || comment !== null;
110
+ const visible = line.split("");
111
+ const runs = backtickRuns(line);
112
+ const mask = (start, end) => visible.fill(" ", start, end);
113
+ let offset = scanOffset;
114
+ while (offset < line.length) {
115
+ if (comment) {
116
+ const close = /--!?>/.exec(line.slice(offset));
117
+ const end = close === null ? line.length : offset + close.index + close[0].length;
118
+ mask(offset, end);
119
+ if (close === null)
120
+ break;
121
+ comment = null;
122
+ offset = end;
123
+ continue;
124
+ }
125
+ if (codeSpan !== null) {
126
+ const close = nextBacktickRun(runs, offset, codeSpan);
127
+ mask(offset, close ? close.index + close.length : line.length);
128
+ if (!close)
129
+ break;
130
+ codeSpan = null;
131
+ offset = close.index + close.length;
132
+ continue;
133
+ }
134
+ const commentAt = line.indexOf("<!--", offset);
135
+ const nextRun = allowCodeOpeners ? nextCodeOpener(runs, offset) : undefined;
136
+ if (commentAt !== -1 && (!nextRun || commentAt < nextRun.index)) {
137
+ let slashes = 0;
138
+ while (line[commentAt - slashes - 1] === "\\")
139
+ slashes++;
140
+ if (slashes % 2 === 1 || inQuotedHtmlAttribute(line, commentAt)) {
141
+ offset = commentAt + 4;
142
+ continue;
143
+ }
144
+ mask(commentAt, commentAt + 4);
145
+ comment = { container: activeContainer, inline: line.slice(0, commentAt).trim() !== "" };
146
+ offset = commentAt + 4;
147
+ continue;
148
+ }
149
+ if (!nextRun)
150
+ break;
151
+ if (!nextRun.escaped && hasCloser(lines, lineIndex, nextRun.index, nextRun.length)) {
152
+ codeSpan = nextRun.length;
153
+ mask(nextRun.index, nextRun.index + nextRun.length);
154
+ }
155
+ offset = nextRun.index + nextRun.length;
156
+ }
157
+ const visiblePrefix = visible.join("");
158
+ push(startsMasked || visiblePrefix === "", visiblePrefix);
159
+ }
160
+ return {
161
+ lines: result,
162
+ safeAtEof: comment === null && fence === null && (html === null || html.kind === "blank-line"),
163
+ };
164
+ }
165
+ export const scanMarkdownLines = (lines) => scanMarkdown(lines).lines;
166
+ export const isSafeMarkdownInsertionPoint = (lines) => scanMarkdown(lines).safeAtEof;
@@ -0,0 +1,7 @@
1
+ type MarkdownLine = {
2
+ ignored: boolean;
3
+ nested: boolean;
4
+ visiblePrefix: string;
5
+ };
6
+ export declare function setextParagraphStart(lines: string[], syntax: MarkdownLine[], underline: number): number | null;
7
+ export {};
@@ -0,0 +1,15 @@
1
+ const NON_PARAGRAPH = /^ {0,3}(?:#{1,6}(?:[ \t]+|$)|>|(?:[-+*]|\d{1,9}[.)])[ \t]+)/;
2
+ function isParagraphText(line, syntax) {
3
+ return (!syntax.ignored &&
4
+ !syntax.nested &&
5
+ line.trim() !== "" &&
6
+ !NON_PARAGRAPH.test(syntax.visiblePrefix));
7
+ }
8
+ export function setextParagraphStart(lines, syntax, underline) {
9
+ let start = underline - 1;
10
+ if (start < 0 || !isParagraphText(lines[start], syntax[start]))
11
+ return null;
12
+ while (start > 0 && isParagraphText(lines[start - 1], syntax[start - 1]))
13
+ start--;
14
+ return start;
15
+ }
@@ -0,0 +1,2 @@
1
+ export declare function isIndentedCode(line: string): boolean;
2
+ export declare function structuralDetailsStart(line: string): number | null;
@@ -0,0 +1,35 @@
1
+ export function isIndentedCode(line) {
2
+ let columns = 0;
3
+ for (const char of line) {
4
+ if (char !== " " && char !== "\t")
5
+ break;
6
+ columns += char === "\t" ? 4 - (columns % 4) : 1;
7
+ }
8
+ return columns >= 4;
9
+ }
10
+ export function structuralDetailsStart(line) {
11
+ let index = 0;
12
+ while (line[index] === " " && index < 4)
13
+ index++;
14
+ if (index > 3 || line[index] === "\t")
15
+ return null;
16
+ if (/^<(?:details(?:\s+[^>]*)?\/?|\/details)>/i.test(line.slice(index)))
17
+ return index;
18
+ const marker = line.slice(index).match(/^(?:[-+*]|\d{1,9}[.)])/);
19
+ if (!marker)
20
+ return null;
21
+ index += marker[0].length;
22
+ let column = index;
23
+ let padding = 0;
24
+ while (line[index] === " " || line[index] === "\t") {
25
+ const nextColumn = line[index] === "\t" ? column + (4 - (column % 4)) : column + 1;
26
+ padding += nextColumn - column;
27
+ column = nextColumn;
28
+ index++;
29
+ }
30
+ return padding > 0 &&
31
+ padding <= 4 &&
32
+ /^<(?:details(?:\s+[^>]*)?\/?|\/details)>/i.test(line.slice(index))
33
+ ? index
34
+ : null;
35
+ }
@@ -0,0 +1,17 @@
1
+ export type ShepherdJournalReconcileResult = {
2
+ body: string;
3
+ ok: true;
4
+ } | {
5
+ error: string;
6
+ ok: false;
7
+ };
8
+ export type ShepherdJournalBounds = {
9
+ contentEnd: number;
10
+ contentStart: number;
11
+ end: number;
12
+ format: "details" | "legacy";
13
+ start: number;
14
+ };
15
+ export declare function scanShepherdJournal(lines: string[]): ShepherdJournalBounds | null | "error";
16
+ export declare function containsJournalEntry(lines: string[], item: string): boolean;
17
+ export declare function reconcileShepherdJournal(suppliedBody: string, liveBody: string): ShepherdJournalReconcileResult;
@@ -0,0 +1,184 @@
1
+ import { inQuotedHtmlAttribute } from "./markdown-html.mjs";
2
+ import { isSafeMarkdownInsertionPoint, scanMarkdownLines } from "./markdown-line.mjs";
3
+ 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
+ const SETEXT = /^ {0,3}(?:=+|-+)[ \t]*$/;
8
+ const CLOSE = "</details>";
9
+ const OPEN = "<details>";
10
+ const SUMMARY = "<summary>Shepherd Journal</summary>";
11
+ 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
+ export function scanShepherdJournal(lines) {
37
+ const syntax = scanMarkdownLines(lines);
38
+ const found = [];
39
+ let detailsDepth = 0;
40
+ let legacy = null;
41
+ for (let i = 0; i < lines.length; i++) {
42
+ if (!syntax[i].ignored &&
43
+ !syntax[i].nested &&
44
+ legacy?.end === lines.length &&
45
+ /^ {0,3}#{1,2}(?:[ \t]+|$)/.test(lines[i])) {
46
+ if (LEGACY.test(syntax[i].visiblePrefix))
47
+ return "error";
48
+ legacy.contentEnd = legacy.end = i;
49
+ continue;
50
+ }
51
+ if (syntax[i].ignored || syntax[i].nested)
52
+ continue;
53
+ const visible = syntax[i].visiblePrefix;
54
+ detailsDepth += detailsTags(visible, false);
55
+ const closes = detailsTags(visible, true);
56
+ if (closes) {
57
+ if (detailsDepth >= closes) {
58
+ detailsDepth -= closes;
59
+ continue;
60
+ }
61
+ return "error";
62
+ }
63
+ if (JOURNAL_SUMMARY.test(visible.trimStart())) {
64
+ if (lines[i] !== SUMMARY || lines[i - 1] !== OPEN || lines[i + 1] !== "")
65
+ return "error";
66
+ const end = close(lines, syntax, i + 2);
67
+ if (end === null)
68
+ return "error";
69
+ detailsDepth--;
70
+ found.push({
71
+ contentEnd: end,
72
+ contentStart: i + 2,
73
+ end: end + 1,
74
+ format: "details",
75
+ start: i - 1,
76
+ });
77
+ i = end;
78
+ continue;
79
+ }
80
+ if (LEGACY.test(visible)) {
81
+ if (legacy)
82
+ return "error";
83
+ legacy = {
84
+ contentEnd: lines.length,
85
+ contentStart: i + 1,
86
+ end: lines.length,
87
+ format: "legacy",
88
+ start: i,
89
+ };
90
+ found.push(legacy);
91
+ continue;
92
+ }
93
+ const start = SETEXT.test(visible) ? setextParagraphStart(lines, syntax, i) : null;
94
+ if (legacy && legacy.end === lines.length && start !== null && start >= legacy.contentStart) {
95
+ legacy.contentEnd = legacy.end = start;
96
+ }
97
+ if (legacy && legacy.end === lines.length && lines[i].trim() === CLOSE)
98
+ return "error";
99
+ }
100
+ return detailsDepth !== 0 || found.length > 1 ? "error" : (found[0] ?? null);
101
+ }
102
+ function trim(lines) {
103
+ let a = 0;
104
+ let b = lines.length;
105
+ while (a < b && lines[a].trim() === "")
106
+ a++;
107
+ while (b > a && lines[b - 1].trim() === "")
108
+ b--;
109
+ return lines.slice(a, b);
110
+ }
111
+ function entries(lines) {
112
+ const result = [];
113
+ const syntax = scanMarkdownLines(lines.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)));
114
+ let current = null;
115
+ for (const [index, line] of lines.entries()) {
116
+ if (syntax[index].visiblePrefix.startsWith("- ")) {
117
+ if (current)
118
+ result.push(trim(current));
119
+ current = [line];
120
+ }
121
+ else if (current)
122
+ current.push(line);
123
+ }
124
+ if (current)
125
+ result.push(trim(current));
126
+ return result;
127
+ }
128
+ function hasUnrecognizedLeadingContent(lines) {
129
+ const syntax = scanMarkdownLines(lines.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)));
130
+ const firstEntry = syntax.findIndex((line) => line.visiblePrefix.startsWith("- "));
131
+ if (firstEntry === -1)
132
+ return lines.some((line) => line.trim() !== "");
133
+ return lines.slice(0, firstEntry).some((line) => line.trim() !== "");
134
+ }
135
+ function contains(a, b) {
136
+ return a.length === b.length && a.every((s, i) => stripCr(s) === stripCr(b[i]));
137
+ }
138
+ export function containsJournalEntry(lines, item) {
139
+ const target = item.split("\n");
140
+ return entries(lines).some((entry) => contains(target, entry));
141
+ }
142
+ function fail(reason) {
143
+ return {
144
+ error: `${reason}. Supply every live Shepherd Journal entry verbatim, or omit the journal from the supplied body to preserve it automatically.`,
145
+ ok: false,
146
+ };
147
+ }
148
+ export function reconcileShepherdJournal(suppliedBody, liveBody) {
149
+ const liveLines = liveBody.split("\n");
150
+ const live = scanShepherdJournal(liveBody.replaceAll("\r\n", "\n").split("\n"));
151
+ const suppliedLines = suppliedBody.split("\n");
152
+ const supplied = scanShepherdJournal(suppliedBody.replaceAll("\r\n", "\n").split("\n"));
153
+ if (supplied === "error" || live === "error")
154
+ return fail("malformed, duplicate, or ambiguous Shepherd Journal container");
155
+ if (!live)
156
+ return { body: suppliedBody, ok: true };
157
+ if (live.format === "details" && supplied?.format === "legacy")
158
+ return fail("canonical Shepherd Journal details container cannot be downgraded to legacy H2");
159
+ const liveContent = trim(liveLines.slice(live.contentStart, live.contentEnd));
160
+ if (!liveContent.length)
161
+ return { body: suppliedBody, ok: true };
162
+ if (!supplied) {
163
+ if (!isSafeMarkdownInsertionPoint(suppliedBody.replaceAll("\r\n", "\n").split("\n")))
164
+ return fail("supplied body ends inside a Markdown construct that would hide the preserved journal");
165
+ const journal = liveLines.slice(live.start, live.end).join("\n");
166
+ const preservedJournal = journal.endsWith("\r") ? `${journal}\n` : journal;
167
+ return {
168
+ body: `${suppliedBody}${suppliedBody === "" ? "" : suppliedBody.endsWith("\n") ? "\n" : "\n\n"}${preservedJournal}`,
169
+ ok: true,
170
+ };
171
+ }
172
+ const liveEntries = entries(liveLines.slice(live.contentStart, live.contentEnd));
173
+ const liveJournalLines = liveLines.slice(live.contentStart, live.contentEnd);
174
+ if (!liveEntries.length || hasUnrecognizedLeadingContent(liveJournalLines))
175
+ return fail("live Shepherd Journal content uses an unrecognized entry format");
176
+ const target = entries(suppliedLines.slice(supplied.contentStart, supplied.contentEnd));
177
+ for (const entry of liveEntries) {
178
+ const match = target.findIndex((candidate) => contains(entry, candidate));
179
+ if (match === -1)
180
+ return fail(`supplied Shepherd Journal would drop live entry ${JSON.stringify(entry[0])}`);
181
+ target.splice(match, 1);
182
+ }
183
+ return { body: suppliedBody, ok: true };
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "keywords": [
6
6
  "automation",
@@ -53,6 +53,10 @@
53
53
  "./classify": {
54
54
  "types": "./src/classify/types.mts",
55
55
  "default": "./bin/classify/types.mjs"
56
+ },
57
+ "./journal": {
58
+ "types": "./bin/journal/index.d.mts",
59
+ "default": "./bin/journal/index.mjs"
56
60
  }
57
61
  },
58
62
  "scripts": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "pr-shepherd": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "pr-shepherd@0.39.0", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@0.40.0", "pr-shepherd-mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pr-shepherd": {
3
3
  "command": "npx",
4
- "args": ["--yes", "--package", "pr-shepherd@0.39.0", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@0.40.0", "pr-shepherd-mcp"]
5
5
  }
6
6
  }