pr-shepherd 0.38.1 → 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.
Files changed (36) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +6 -3
  3. package/bin/cli/iterate-instructions.mjs +2 -2
  4. package/bin/commands/commit-suggestion-instruction.d.mts +6 -3
  5. package/bin/commands/commit-suggestion-instruction.mjs +7 -15
  6. package/bin/commands/iterate/check-instructions.d.mts +31 -1
  7. package/bin/commands/iterate/check-instructions.mjs +36 -18
  8. package/bin/commands/iterate/render.mjs +7 -12
  9. package/bin/commands/journal/journal-item.mjs +57 -7
  10. package/bin/commands/journal/journal-markdown.d.mts +0 -1
  11. package/bin/commands/journal/journal-markdown.mjs +1 -6
  12. package/bin/commands/shepherd-journal.d.mts +7 -3
  13. package/bin/commands/shepherd-journal.mjs +8 -7
  14. package/bin/journal/append.d.mts +8 -0
  15. package/bin/journal/append.mjs +96 -0
  16. package/bin/journal/index.d.mts +3 -0
  17. package/bin/journal/index.mjs +3 -0
  18. package/bin/journal/markdown-backticks.d.mts +9 -0
  19. package/bin/journal/markdown-backticks.mjs +34 -0
  20. package/bin/journal/markdown-container.d.mts +22 -0
  21. package/bin/journal/markdown-container.mjs +110 -0
  22. package/bin/journal/markdown-html.d.mts +20 -0
  23. package/bin/journal/markdown-html.mjs +119 -0
  24. package/bin/journal/markdown-line.d.mts +8 -0
  25. package/bin/journal/markdown-line.mjs +166 -0
  26. package/bin/journal/markdown-setext.d.mts +7 -0
  27. package/bin/journal/markdown-setext.mjs +15 -0
  28. package/bin/journal/markdown-structure.d.mts +2 -0
  29. package/bin/journal/markdown-structure.mjs +35 -0
  30. package/bin/journal/reconcile.d.mts +17 -0
  31. package/bin/journal/reconcile.mjs +184 -0
  32. package/package.json +5 -1
  33. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  34. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  35. package/plugins/pr-shepherd/.mcp.json +1 -1
  36. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +44 -0
@@ -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;