@shadowclone/skills 0.0.0-stage → 0.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +10 -2
- package/package.json +15 -4
- package/skills/choose-by-consequence/SKILL.md +66 -0
- package/skills/design-deep-modules/SKILL.md +51 -0
- package/skills/diagnose-before-editing/SKILL.md +58 -0
- package/skills/guided-pr-review/SKILL.md +87 -0
- package/skills/guided-pr-review/assets/document.mjs +197 -0
- package/skills/guided-pr-review/assets/guide.mjs +196 -0
- package/skills/guided-pr-review/assets/notes.mjs +195 -0
- package/skills/guided-pr-review/assets/presentation.mjs +180 -0
- package/skills/guided-pr-review/assets/review.html +18 -0
- package/skills/guided-pr-review/references/report.md +118 -0
- package/skills/guided-pr-review/scripts/build.mjs +117 -0
- package/skills/guided-pr-review/scripts/check.mjs +193 -0
- package/skills/guided-pr-review/scripts/format.mjs +198 -0
- package/skills/guided-pr-review/scripts/git.mjs +199 -0
- package/skills/guided-pr-review/scripts/render.mjs +195 -0
- package/skills/plan-with-review-page/SKILL.md +73 -0
- package/skills/plan-with-review-page/assets/review-page.html +160 -0
- package/skills/plan-with-review-page/scripts/validate-page.mjs +158 -0
- package/skills/research-primary-sources/SKILL.md +51 -0
- package/skills/resolve-conflicts-by-intent/SKILL.md +53 -0
- package/skills/scope-confirmed-changes/SKILL.md +63 -0
- package/skills/shadowclone-review/SKILL.md +76 -0
- package/skills/shadowclone-work/SKILL.md +47 -0
- package/skills/tests-that-catch-bugs/SKILL.md +73 -0
- package/skills/typescript-type-safety/SKILL.md +52 -0
- package/skills/verify-and-review/SKILL.md +58 -0
- package/skills/verify-review-findings/SKILL.md +59 -0
- package/skills/write-plain-english/SKILL.md +69 -0
- package/skills/write-plain-english/scripts/check-ste.mjs +200 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { ancestor, patchRows } from "./check.mjs";
|
|
4
|
+
|
|
5
|
+
function fileKey(options) {
|
|
6
|
+
return options.pr + ":" + options.path;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function git(options) {
|
|
10
|
+
const result = spawnSync(
|
|
11
|
+
"git",
|
|
12
|
+
["--literal-pathspecs", "-C", options.checkout, ...options.arguments],
|
|
13
|
+
{
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
if (result.error) throw result.error;
|
|
19
|
+
if (result.status !== 0)
|
|
20
|
+
throw new Error("Git evidence failed: " + result.stderr.trim().slice(0, 400));
|
|
21
|
+
return result.stdout;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function source(options) {
|
|
25
|
+
const result = spawnSync(
|
|
26
|
+
"git",
|
|
27
|
+
["-C", options.checkout, "show", options.sha + ":" + options.path],
|
|
28
|
+
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
|
|
29
|
+
);
|
|
30
|
+
if (result.status !== 0) {
|
|
31
|
+
const exists = spawnSync("git", [
|
|
32
|
+
"-C",
|
|
33
|
+
options.checkout,
|
|
34
|
+
"cat-file",
|
|
35
|
+
"-e",
|
|
36
|
+
options.sha + ":" + options.path,
|
|
37
|
+
]);
|
|
38
|
+
if (exists.status === 1 || exists.status === 128) return null;
|
|
39
|
+
throw new Error("A committed source file could not be read.");
|
|
40
|
+
}
|
|
41
|
+
return result.stdout.includes("\0") ? null : result.stdout;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function filesAt(options) {
|
|
45
|
+
const names = git({
|
|
46
|
+
checkout: options.checkout,
|
|
47
|
+
arguments: ["diff", "--name-status", "-z", "--find-renames", options.base, options.head],
|
|
48
|
+
}).split("\0");
|
|
49
|
+
const files = [];
|
|
50
|
+
while (names.length && names[0]) {
|
|
51
|
+
const status = names.shift();
|
|
52
|
+
const oldPath = names.shift();
|
|
53
|
+
const path = /^[RC]/.test(status) ? names.shift() : oldPath;
|
|
54
|
+
const patch = git({
|
|
55
|
+
checkout: options.checkout,
|
|
56
|
+
arguments: [
|
|
57
|
+
"diff",
|
|
58
|
+
"--no-color",
|
|
59
|
+
"--no-ext-diff",
|
|
60
|
+
"--no-textconv",
|
|
61
|
+
"--find-renames",
|
|
62
|
+
"--unified=5",
|
|
63
|
+
options.base,
|
|
64
|
+
options.head,
|
|
65
|
+
"--",
|
|
66
|
+
...new Set([oldPath, path]),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
const rows = patchRows(patch).map((row) => ({ ...row, owner: options.pr }));
|
|
70
|
+
const isMarkdown = /\.(md|mdx|markdown)$/i.test(path);
|
|
71
|
+
files.push({
|
|
72
|
+
id: fileKey({ pr: options.pr, path }),
|
|
73
|
+
pr: options.pr,
|
|
74
|
+
path,
|
|
75
|
+
diffAnchor: "diff-" + createHash("sha256").update(path).digest("hex"),
|
|
76
|
+
oldPath,
|
|
77
|
+
status,
|
|
78
|
+
rows,
|
|
79
|
+
patch: rows.length ? null : patch,
|
|
80
|
+
additions: rows.filter((row) => row.kind === "add").length,
|
|
81
|
+
deletions: rows.filter((row) => row.kind === "remove").length,
|
|
82
|
+
baseMarkdown: isMarkdown ? source({ ...options, sha: options.base, path: oldPath }) : null,
|
|
83
|
+
headMarkdown: isMarkdown ? source({ ...options, sha: options.head, path }) : null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return files;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function addedOwners(options) {
|
|
90
|
+
const text = git({
|
|
91
|
+
checkout: options.checkout,
|
|
92
|
+
arguments: ["blame", "--line-porcelain", options.head, "--", options.path],
|
|
93
|
+
});
|
|
94
|
+
const owners = new Map();
|
|
95
|
+
let line = 0;
|
|
96
|
+
let commit = "";
|
|
97
|
+
for (const value of text.split("\n")) {
|
|
98
|
+
const header = /^([a-f0-9]{40}) \d+ (\d+)/.exec(value);
|
|
99
|
+
if (header) {
|
|
100
|
+
commit = header[1];
|
|
101
|
+
line = Number(header[2]);
|
|
102
|
+
}
|
|
103
|
+
if (value.startsWith("\t"))
|
|
104
|
+
owners.set(line, options.pulls.find((pull) => pull.commits.has(commit))?.id ?? "context");
|
|
105
|
+
}
|
|
106
|
+
return owners;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function collectReview(input) {
|
|
110
|
+
const pulls = [];
|
|
111
|
+
for (const pull of input.pulls) {
|
|
112
|
+
for (const sha of [pull.baseSha, pull.headSha])
|
|
113
|
+
git({ checkout: pull.checkout, arguments: ["cat-file", "-e", sha + "^{commit}"] });
|
|
114
|
+
const mergeBaseSha = git({
|
|
115
|
+
checkout: pull.checkout,
|
|
116
|
+
arguments: ["merge-base", pull.baseSha, pull.headSha],
|
|
117
|
+
}).trim();
|
|
118
|
+
const files = filesAt({
|
|
119
|
+
checkout: pull.checkout,
|
|
120
|
+
base: mergeBaseSha,
|
|
121
|
+
head: pull.headSha,
|
|
122
|
+
pr: pull.id,
|
|
123
|
+
});
|
|
124
|
+
const { checkout, reviewFile, ...publicPull } = pull;
|
|
125
|
+
pulls.push({
|
|
126
|
+
...publicPull,
|
|
127
|
+
mergeBaseSha,
|
|
128
|
+
files,
|
|
129
|
+
findings: [],
|
|
130
|
+
skippedPaths: [],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
let combined = null;
|
|
134
|
+
const endpoint =
|
|
135
|
+
input.pulls.length > 1 &&
|
|
136
|
+
input.pulls.find((candidate) =>
|
|
137
|
+
input.pulls.every(
|
|
138
|
+
(pull) =>
|
|
139
|
+
pull.repository === candidate.repository &&
|
|
140
|
+
ancestor({ checkout: candidate.checkout, base: pull.headSha, head: candidate.headSha }),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
if (endpoint) {
|
|
144
|
+
const baseSha = git({
|
|
145
|
+
checkout: endpoint.checkout,
|
|
146
|
+
arguments: ["merge-base", "--octopus", ...pulls.map((pull) => pull.mergeBaseSha)],
|
|
147
|
+
}).trim();
|
|
148
|
+
const ordered = [...pulls].sort((first, second) =>
|
|
149
|
+
ancestor({ checkout: endpoint.checkout, base: first.headSha, head: second.headSha }) ? -1 : 1,
|
|
150
|
+
);
|
|
151
|
+
const ownership = ordered.map((pull) => ({
|
|
152
|
+
...pull,
|
|
153
|
+
commits: new Set(
|
|
154
|
+
git({
|
|
155
|
+
checkout: endpoint.checkout,
|
|
156
|
+
arguments: ["rev-list", pull.headSha, "^" + pull.mergeBaseSha],
|
|
157
|
+
})
|
|
158
|
+
.trim()
|
|
159
|
+
.split("\n"),
|
|
160
|
+
),
|
|
161
|
+
}));
|
|
162
|
+
const files = filesAt({
|
|
163
|
+
checkout: endpoint.checkout,
|
|
164
|
+
base: baseSha,
|
|
165
|
+
head: endpoint.headSha,
|
|
166
|
+
pr: "stack",
|
|
167
|
+
});
|
|
168
|
+
for (const file of files) {
|
|
169
|
+
const owners = file.rows.some((row) => row.newLine !== null)
|
|
170
|
+
? addedOwners({
|
|
171
|
+
checkout: endpoint.checkout,
|
|
172
|
+
head: endpoint.headSha,
|
|
173
|
+
path: file.path,
|
|
174
|
+
pulls: ownership,
|
|
175
|
+
})
|
|
176
|
+
: new Map();
|
|
177
|
+
for (const row of file.rows) {
|
|
178
|
+
if (row.kind === "add") row.owner = owners.get(row.newLine) ?? "context";
|
|
179
|
+
else if (row.kind === "remove")
|
|
180
|
+
row.owner =
|
|
181
|
+
ordered
|
|
182
|
+
.filter((pull) =>
|
|
183
|
+
pull.files.some(
|
|
184
|
+
(entry) =>
|
|
185
|
+
entry.oldPath === file.oldPath &&
|
|
186
|
+
entry.rows.some(
|
|
187
|
+
(removed) => removed.kind === "remove" && removed.text === row.text,
|
|
188
|
+
),
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
.map((pull) => pull.id)
|
|
192
|
+
.join(",") || "stack";
|
|
193
|
+
else row.owner = "context";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
combined = { baseSha, headSha: endpoint.headSha, files };
|
|
197
|
+
}
|
|
198
|
+
return { pulls, combined };
|
|
199
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import {
|
|
3
|
+
reviewStyle,
|
|
4
|
+
findingMarkup,
|
|
5
|
+
setSourceLink,
|
|
6
|
+
sourceExcerptMarkup,
|
|
7
|
+
} from "../assets/presentation.mjs";
|
|
8
|
+
import { escapeHtml, jsonForHtml, markdown, fileCard, behaviorMarkup } from "./format.mjs";
|
|
9
|
+
|
|
10
|
+
function sourceLinks(options) {
|
|
11
|
+
return [...new Set(options.ids)]
|
|
12
|
+
.map((id) => {
|
|
13
|
+
const item = options.review.evidence.find((item) => item.id === id);
|
|
14
|
+
return (
|
|
15
|
+
'<button class="source-link" data-evidence="' +
|
|
16
|
+
id +
|
|
17
|
+
'" type="button" title="' +
|
|
18
|
+
escapeHtml(item.path) +
|
|
19
|
+
'">' +
|
|
20
|
+
escapeHtml(item.label ?? item.path.split("/").pop() + ":" + item.start) +
|
|
21
|
+
"</button>"
|
|
22
|
+
);
|
|
23
|
+
})
|
|
24
|
+
.join(" ");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function groupMarkup(options) {
|
|
28
|
+
const { group, review, index } = options;
|
|
29
|
+
const numbers = group.prs
|
|
30
|
+
.map((id) => "#" + review.pulls.find((pull) => pull.id === id).number)
|
|
31
|
+
.join(" + ");
|
|
32
|
+
const placedPaths = new Set();
|
|
33
|
+
const { flow, dryRuns } = behaviorMarkup({
|
|
34
|
+
group,
|
|
35
|
+
sources: (ids) => sourceLinks({ review, ids }),
|
|
36
|
+
});
|
|
37
|
+
const fileSlots = (paths) =>
|
|
38
|
+
[...new Set(paths)]
|
|
39
|
+
.filter((path) => !placedPaths.has(path))
|
|
40
|
+
.map((path) => {
|
|
41
|
+
placedPaths.add(path);
|
|
42
|
+
const related = group.files.filter((item) => item.path === path);
|
|
43
|
+
const combinedFile = review.combined?.files.find((file) => file.path === path);
|
|
44
|
+
const combinedCard = combinedFile
|
|
45
|
+
? fileCard({
|
|
46
|
+
file: combinedFile,
|
|
47
|
+
open: true,
|
|
48
|
+
prLabel: "Combined stack",
|
|
49
|
+
url: review.pulls.find((pull) => pull.id === related.at(-1).pr).url,
|
|
50
|
+
})
|
|
51
|
+
: "";
|
|
52
|
+
const cards = related
|
|
53
|
+
.map((item) => {
|
|
54
|
+
const pull = review.pulls.find((pull) => pull.id === item.pr);
|
|
55
|
+
return fileCard({
|
|
56
|
+
file: pull.files.find((file) => file.path === path),
|
|
57
|
+
open: true,
|
|
58
|
+
prLabel: "#" + pull.number,
|
|
59
|
+
url: pull.url,
|
|
60
|
+
});
|
|
61
|
+
})
|
|
62
|
+
.join("");
|
|
63
|
+
return (
|
|
64
|
+
'<div class="file-slot" data-path="' +
|
|
65
|
+
escapeHtml(path) +
|
|
66
|
+
'" data-prs="' +
|
|
67
|
+
related.map((item) => item.pr).join(",") +
|
|
68
|
+
'">' +
|
|
69
|
+
combinedCard +
|
|
70
|
+
cards +
|
|
71
|
+
"</div>"
|
|
72
|
+
);
|
|
73
|
+
})
|
|
74
|
+
.join("");
|
|
75
|
+
const passages = group.steps
|
|
76
|
+
.map((step, stepIndex) => {
|
|
77
|
+
const files =
|
|
78
|
+
step.files ??
|
|
79
|
+
(step.evidence ?? [])
|
|
80
|
+
.map((id) => review.evidence.find((item) => item.id === id))
|
|
81
|
+
.filter((item) =>
|
|
82
|
+
group.files.some((file) => file.pr === item.pr && file.path === item.path),
|
|
83
|
+
);
|
|
84
|
+
const definitions = (step.definitions ?? [])
|
|
85
|
+
.map((id) =>
|
|
86
|
+
sourceExcerptMarkup({
|
|
87
|
+
item: review.evidence.find((item) => item.id === id),
|
|
88
|
+
pulls: review.pulls,
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
.join("");
|
|
92
|
+
return `<section class="passage" data-anchor="${group.id}:passage:${stepIndex}" data-prs="${group.prs.join(",")}"><h3>${escapeHtml(step.title)}</h3>${markdown(step.text)}${definitions}<div class="sources">${sourceLinks({ review, ids: step.evidence ?? [] })}</div><div class="inline-notes" data-notes-at="${group.id}:passage:${stepIndex}"></div></section>${fileSlots(files.map((file) => file.path))}`;
|
|
93
|
+
})
|
|
94
|
+
.join("");
|
|
95
|
+
const remaining = fileSlots(group.files.map((file) => file.path));
|
|
96
|
+
return `<article class="reading-step" id="step-${group.id}" data-group="${group.id}" ${index ? "hidden" : ""}><div class="step-meta"><span>Step ${index + 1} of ${review.groups.length} · ${numbers}</span><label><input type="checkbox" data-reviewed="${group.id}"> Reviewed</label></div><h1>${escapeHtml(group.title)}</h1><div class="passage" data-anchor="${group.id}:summary" data-prs="${group.prs.join(",")}">${markdown(group.summary)}<div class="inline-notes" data-notes-at="${group.id}:summary"></div></div><p class="why">${escapeHtml(group.why)}</p><div class="before-after"><div><span>Before</span><p>${escapeHtml(group.before)}</p></div><div><span>After</span><p>${escapeHtml(group.after)}</p></div></div>${flow}${passages}${remaining ? '<div class="step-files"><h3>Remaining file changes</h3>' + remaining + "</div>" : ""}<div class="group-findings" data-findings-for="${group.id}"></div>${dryRuns}</article>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function renderReview(review) {
|
|
100
|
+
const asset = async (name) => readFile(new URL("../assets/" + name, import.meta.url), "utf8");
|
|
101
|
+
const count = review.pulls.reduce((total, pull) => total + pull.files.length, 0);
|
|
102
|
+
const findings = review.pulls.reduce((total, pull) => total + pull.findings.length, 0);
|
|
103
|
+
const prTabs =
|
|
104
|
+
review.pulls.length > 1
|
|
105
|
+
? '<div class="pr-tabs">' +
|
|
106
|
+
(review.combined ? '<button data-pr="stack" type="button">Combined stack</button>' : "") +
|
|
107
|
+
review.pulls
|
|
108
|
+
.map(
|
|
109
|
+
(pull) =>
|
|
110
|
+
'<button data-pr="' +
|
|
111
|
+
pull.id +
|
|
112
|
+
'" type="button">#' +
|
|
113
|
+
pull.number +
|
|
114
|
+
" " +
|
|
115
|
+
escapeHtml(pull.title) +
|
|
116
|
+
"</button>",
|
|
117
|
+
)
|
|
118
|
+
.join("") +
|
|
119
|
+
"</div>"
|
|
120
|
+
: "";
|
|
121
|
+
const sidebar =
|
|
122
|
+
'<aside class="route"><h2>Reading route</h2>' +
|
|
123
|
+
review.groups
|
|
124
|
+
.map(
|
|
125
|
+
(group, index) =>
|
|
126
|
+
'<button class="route-step" data-step="' +
|
|
127
|
+
index +
|
|
128
|
+
'" type="button"><span>' +
|
|
129
|
+
(index + 1) +
|
|
130
|
+
"</span><span>" +
|
|
131
|
+
escapeHtml(group.title) +
|
|
132
|
+
"<small>" +
|
|
133
|
+
group.prs
|
|
134
|
+
.map((id) => "#" + review.pulls.find((pull) => pull.id === id).number)
|
|
135
|
+
.join(" + ") +
|
|
136
|
+
" · " +
|
|
137
|
+
group.files.length +
|
|
138
|
+
" file changes</small></span></button>",
|
|
139
|
+
)
|
|
140
|
+
.join("") +
|
|
141
|
+
'<div class="route-footer">' +
|
|
142
|
+
count +
|
|
143
|
+
" file changes across " +
|
|
144
|
+
review.pulls.length +
|
|
145
|
+
" PR" +
|
|
146
|
+
(review.pulls.length === 1 ? "" : "s") +
|
|
147
|
+
"<p>Read the code. Make your own notes. Then compare the agent findings.</p></div></aside>";
|
|
148
|
+
const ci = review.pulls
|
|
149
|
+
.map((pull) => {
|
|
150
|
+
const counts = new Map();
|
|
151
|
+
for (const check of pull.ci ?? [])
|
|
152
|
+
counts.set(
|
|
153
|
+
check.conclusion ?? check.state ?? "pending",
|
|
154
|
+
(counts.get(check.conclusion ?? check.state ?? "pending") ?? 0) + 1,
|
|
155
|
+
);
|
|
156
|
+
return (
|
|
157
|
+
'<p><a href="' +
|
|
158
|
+
escapeHtml(pull.url) +
|
|
159
|
+
'" target="_blank" rel="noreferrer">#' +
|
|
160
|
+
pull.number +
|
|
161
|
+
" " +
|
|
162
|
+
escapeHtml(pull.title) +
|
|
163
|
+
"</a><br><code>" +
|
|
164
|
+
pull.headSha.slice(0, 10) +
|
|
165
|
+
"</code> · " +
|
|
166
|
+
pull.files.length +
|
|
167
|
+
" files · " +
|
|
168
|
+
[...counts].map(([status, total]) => total + " " + status.toLowerCase()).join(", ") +
|
|
169
|
+
"</p>"
|
|
170
|
+
);
|
|
171
|
+
})
|
|
172
|
+
.join("");
|
|
173
|
+
const body = `<header><strong class="brand">◇ shadowclone <span>/ guided review</span></strong><div class="header-actions"><button id="toggle-findings" type="button">Agent findings <span>${findings}</span></button><button id="toggle-notes" type="button">Your notes <span id="note-count">0</span></button><button id="save-review" type="button">Save review</button><span id="save-status" role="status">Saved HTML</span></div></header><div class="document">${sidebar}<main><div class="overview"><h2>${escapeHtml(review.title)}</h2>${markdown(review.summary)}${review.status === "prototype" ? '<p class="prototype">Prototype: one part of the reading route. The full file inventory remains available.</p>' : ""}${prTabs}<div class="view-tabs"><button data-mode="guide" type="button">Guide</button><button data-mode="files" type="button">All changes (${count})</button></div><details class="pr-details"><summary>PR details and CI</summary>${ci}</details></div><div id="guide">${review.groups.map((group, index) => groupMarkup({ group, review, index })).join("")}<nav class="step-navigation"><button id="previous" type="button">Previous</button><button id="next" type="button">Next</button></nav></div><section id="files" hidden><h1>Every changed file</h1><p>These are the actual per-PR changes, including files outside the main reading step.</p><input id="file-search" type="search" placeholder="Find a file" aria-label="Find a changed file"><div id="file-list"></div></section></main></div><aside id="notes-panel" hidden><button id="close-notes" type="button">Close</button><section class="human-notes"><h2>Your review notes</h2><p>Save this HTML, then attach it to your chat and request staging.</p><div id="notes-list"></div></section><section class="agent-findings"><h2 id="findings-heading" hidden>Agent findings</h2><div id="findings-list"></div></section></aside><aside id="composer" hidden><form id="note-form"><div class="composer-title"><strong>Your note</strong><button id="cancel-note" type="button" aria-label="Cancel note">×</button></div><blockquote id="selected-quote"></blockquote><label id="target-label">Post on <select id="target-pr"></select></label><textarea id="note-body" aria-label="Your rough note" placeholder="Write the concern or question in your words." required></textarea><button type="submit">Save note</button></form></aside><button id="selection-comment" type="button" hidden>Comment</button><dialog id="source-dialog"><button id="close-source" type="button">Close</button><h2 id="source-title"></h2><p id="source-pin"></p><pre><code id="source-code"></code></pre><a id="source-link" target="_blank" rel="noreferrer">Open pinned source</a></dialog>`;
|
|
174
|
+
const format = (await readFile(new URL("./format.mjs", import.meta.url), "utf8")).replace(
|
|
175
|
+
/^export /gm,
|
|
176
|
+
"",
|
|
177
|
+
);
|
|
178
|
+
const scripts = [
|
|
179
|
+
format,
|
|
180
|
+
findingMarkup.toString(),
|
|
181
|
+
setSourceLink.toString(),
|
|
182
|
+
...(await Promise.all(["document.mjs", "notes.mjs", "guide.mjs"].map(asset))),
|
|
183
|
+
]
|
|
184
|
+
.map((script) => script.replace(/^export /gm, ""))
|
|
185
|
+
.join("\n");
|
|
186
|
+
return (await asset("review.html"))
|
|
187
|
+
.replace("{{TITLE}}", () => escapeHtml(review.title))
|
|
188
|
+
.replace("{{STYLE}}", () => reviewStyle)
|
|
189
|
+
.replace("{{BODY}}", () => body)
|
|
190
|
+
.replace("{{DATA}}", () => jsonForHtml(review))
|
|
191
|
+
.replace(
|
|
192
|
+
'<script type="module" id="review-runtime"></script>',
|
|
193
|
+
() => '<script type="module" id="review-runtime">' + scripts + "</script>",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plan-with-review-page
|
|
3
|
+
description: 'Use before a change that touches many files, needs many steps, or has open design choices. Also use it when the user says "plan this first", "what is your approach?", or "do not code yet". Explores the code read-only, asks one round of decisions with the recommended option first, and writes one plan. Builds a self-contained HTML review page from a template, and checks the page with a validator. Not for checking finished work (use `verify-and-review`).'
|
|
4
|
+
metadata:
|
|
5
|
+
shadowclone-category: workflow
|
|
6
|
+
shadowclone-section: workflow
|
|
7
|
+
shadowclone-applies-when: before a change that has many steps or open design choices
|
|
8
|
+
---
|
|
9
|
+
# Plan With a Review Page
|
|
10
|
+
|
|
11
|
+
## Use when
|
|
12
|
+
|
|
13
|
+
A change touches many files, needs many steps, or has a design choice that belongs to the user. An approved plan stops work in the wrong direction. A small and clear change does not need this skill.
|
|
14
|
+
|
|
15
|
+
## Gates
|
|
16
|
+
|
|
17
|
+
1. Are you read-only until the user approves? Before approval, write only the plan file and the review page.
|
|
18
|
+
2. Did you read the code, the documents, and the history before you asked? Never ask what the code can answer.
|
|
19
|
+
3. Did you ask one round of at most 4 questions, each with 2 to 4 options and the recommended option first?
|
|
20
|
+
4. Is each risky behavior choice in that round, such as a skip, a block, a fallback, or a default?
|
|
21
|
+
5. Is there one plan file for this conversation, with each skipped step still visible and its reason?
|
|
22
|
+
6. Does the review page pass `scripts/validate-page.mjs` with 0 errors?
|
|
23
|
+
|
|
24
|
+
## Process
|
|
25
|
+
|
|
26
|
+
### Explore
|
|
27
|
+
|
|
28
|
+
1. Read the code that the request touches, and the instructions of the repository for that code.
|
|
29
|
+
2. Find the functions and patterns that the change can use again.
|
|
30
|
+
|
|
31
|
+
### Ask
|
|
32
|
+
|
|
33
|
+
1. List each open decision about scope, behavior, edge cases, data, release, and tests.
|
|
34
|
+
2. Ask the decisions in one round of at most 4 questions. Put the recommended option first, with one reason.
|
|
35
|
+
3. If an answer is not clear, ask again. Do not guess.
|
|
36
|
+
|
|
37
|
+
### Write
|
|
38
|
+
|
|
39
|
+
1. Put the plan at `/tmp/agent-artifacts/<repo>/<date>-<slug>/plan.md`. If the sandbox blocks `/tmp`, use the temporary folder of the agent.
|
|
40
|
+
2. Write the context, the decisions with their source, the approach, the files, the steps, and the verification commands.
|
|
41
|
+
3. Copy `assets/review-page.html` to the same folder as `index.html`. Edit only the JSON block with the id `plan`, and replace each value that starts with `TEMPLATE:`.
|
|
42
|
+
4. Add Mermaid source in `diagram` when the change has more than two parts. The page shows the source as text, and draws it when the pinned Mermaid file loads.
|
|
43
|
+
5. Run `node scripts/validate-page.mjs <page>`. If Node is not available, run the script with `bun`. Fix each error.
|
|
44
|
+
6. Give the user both paths, the approach, and the main risk. Wait for approval.
|
|
45
|
+
|
|
46
|
+
### Execute
|
|
47
|
+
|
|
48
|
+
1. Do the steps in order. After each step, update its status in the page and in the plan.
|
|
49
|
+
2. If a fact changes a decision, stop. Update the plan, and ask again.
|
|
50
|
+
3. Keep a step that you skip in the list, with the status `skipped` and the reason in its note.
|
|
51
|
+
|
|
52
|
+
## Example
|
|
53
|
+
|
|
54
|
+
**Situation:** The user asks for a sync command that updates installed skills. Some installed copies contain edits that the user made.
|
|
55
|
+
**Easy route:** Plan to replace each copy with the new version, and do not ask.
|
|
56
|
+
**Hidden cost:** The plan hides a choice that deletes the work of the user. The user finds the loss after the release.
|
|
57
|
+
**Best route:** Put the choice in the question round: keep each edited copy and list it (recommended), or replace it and save a backup. Record the answer in the plan.
|
|
58
|
+
**Evidence:** The decision table of the page shows "Edited copies: keep and list" with the source "user". The validator printed "validate-page: 0 errors in 1 file".
|
|
59
|
+
|
|
60
|
+
## Guardrails
|
|
61
|
+
|
|
62
|
+
- Approval covers only the steps in the plan. Ask before a step that the plan does not list.
|
|
63
|
+
- Never commit the plan or the review page to a repository.
|
|
64
|
+
- Keep the review page self-contained. Do not add scripts, styles, or fonts from the network.
|
|
65
|
+
- Do not open a decision again unless a new fact changes it.
|
|
66
|
+
- Keep the plan short enough to scan. Write the recommended approach, not each option that you considered.
|
|
67
|
+
|
|
68
|
+
## Completion
|
|
69
|
+
|
|
70
|
+
- The plan path and the review page path.
|
|
71
|
+
- The validator command and its result line.
|
|
72
|
+
- The question round, with the recommended option first in each question.
|
|
73
|
+
- Each step with its status, and each skipped step with its reason.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Plan review</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--page: #fbfaf7; --text: #1d1d1b; --muted: #66645f; --card: #ffffff; --line: #e2dfd8;
|
|
10
|
+
--done: #2f7d4f; --doing: #a8701c; --todo: #77756f; --blocked: #b5443b; --skipped: #6d5fa8;
|
|
11
|
+
}
|
|
12
|
+
@media (prefers-color-scheme: dark) {
|
|
13
|
+
:root {
|
|
14
|
+
--page: #161615; --text: #ecebe7; --muted: #a09e98; --card: #1f1f1d; --line: #34332f;
|
|
15
|
+
--done: #5fbf83; --doing: #e0a84a; --todo: #8f8c85; --blocked: #e57b72; --skipped: #a99be8;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
* { box-sizing: border-box; }
|
|
19
|
+
body { margin: 0; background: var(--page); color: var(--text); font: 15px/1.55 system-ui, sans-serif; }
|
|
20
|
+
main { max-width: 960px; margin: 0 auto; padding: 32px 16px 64px; }
|
|
21
|
+
h1 { font-size: 24px; margin: 0 0 6px; }
|
|
22
|
+
h2 { font-size: 17px; margin: 28px 0 10px; }
|
|
23
|
+
.meta { color: var(--muted); margin: 0 0 18px; }
|
|
24
|
+
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
|
|
25
|
+
.counts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
|
|
26
|
+
.counts b { display: block; font-size: 22px; }
|
|
27
|
+
.counts span { color: var(--muted); font-size: 13px; }
|
|
28
|
+
ol.steps { list-style: none; margin: 0; padding: 0; }
|
|
29
|
+
ol.steps li { display: grid; grid-template-columns: 84px 1fr; gap: 12px; padding: 9px 0; border-bottom: 1px solid var(--line); }
|
|
30
|
+
ol.steps li:last-child { border-bottom: 0; }
|
|
31
|
+
.status { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
|
|
32
|
+
.done { color: var(--done); } .doing { color: var(--doing); } .todo { color: var(--todo); }
|
|
33
|
+
.blocked { color: var(--blocked); } .skipped { color: var(--skipped); }
|
|
34
|
+
.note { display: block; color: var(--muted); font-size: 13px; }
|
|
35
|
+
.scroll { overflow-x: auto; }
|
|
36
|
+
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
37
|
+
th, td { text-align: left; vertical-align: top; padding: 7px 8px; border-bottom: 1px solid var(--line); }
|
|
38
|
+
th { color: var(--muted); font-weight: 600; }
|
|
39
|
+
code, pre { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
40
|
+
pre { white-space: pre-wrap; margin: 0; }
|
|
41
|
+
#diagram img { display: block; max-width: 100%; height: auto; margin-bottom: 10px; }
|
|
42
|
+
</style>
|
|
43
|
+
</head>
|
|
44
|
+
<body>
|
|
45
|
+
<main>
|
|
46
|
+
<h1 id="title"></h1>
|
|
47
|
+
<p id="goal"></p>
|
|
48
|
+
<p class="meta">Plan file: <code id="plan-path"></code>. Updated: <span id="updated"></span>.</p>
|
|
49
|
+
<div class="counts">
|
|
50
|
+
<div class="card"><b id="count-done">0</b><span>steps done</span></div>
|
|
51
|
+
<div class="card"><b id="count-left">0</b><span>steps left</span></div>
|
|
52
|
+
<div class="card"><b id="count-open">0</b><span>open questions</span></div>
|
|
53
|
+
</div>
|
|
54
|
+
<section id="diagram-section" hidden>
|
|
55
|
+
<h2>Diagram</h2>
|
|
56
|
+
<div class="card scroll">
|
|
57
|
+
<div id="diagram"></div>
|
|
58
|
+
<p class="note" id="diagram-note">The diagram shows as source text until the pinned Mermaid file loads.</p>
|
|
59
|
+
<pre id="diagram-source"></pre>
|
|
60
|
+
</div>
|
|
61
|
+
</section>
|
|
62
|
+
<h2>Steps</h2>
|
|
63
|
+
<div class="card"><ol class="steps" id="steps"></ol></div>
|
|
64
|
+
<h2>Decisions</h2>
|
|
65
|
+
<div class="card scroll">
|
|
66
|
+
<table>
|
|
67
|
+
<thead><tr><th>Question</th><th>Answer</th><th>Source</th></tr></thead>
|
|
68
|
+
<tbody id="decisions"></tbody>
|
|
69
|
+
</table>
|
|
70
|
+
</div>
|
|
71
|
+
<h2>Open questions</h2>
|
|
72
|
+
<div class="card"><ul id="open"></ul></div>
|
|
73
|
+
</main>
|
|
74
|
+
<script type="application/json" id="plan">
|
|
75
|
+
{
|
|
76
|
+
"title": "TEMPLATE: A short name for the change",
|
|
77
|
+
"goal": "TEMPLATE: One sentence about the result that the user wants.",
|
|
78
|
+
"planPath": "TEMPLATE: The absolute path of the plan file",
|
|
79
|
+
"updated": "TEMPLATE: The date and time of this update",
|
|
80
|
+
"steps": [
|
|
81
|
+
{ "status": "done", "text": "TEMPLATE: Read the code that the request touches.", "note": "" },
|
|
82
|
+
{ "status": "doing", "text": "TEMPLATE: Ask one round of decisions.", "note": "" },
|
|
83
|
+
{ "status": "todo", "text": "TEMPLATE: Write the plan and this page.", "note": "" },
|
|
84
|
+
{ "status": "skipped", "text": "TEMPLATE: A step that you skipped.", "note": "TEMPLATE: The reason for the skip." }
|
|
85
|
+
],
|
|
86
|
+
"decisions": [
|
|
87
|
+
{ "question": "TEMPLATE: A decision that the user made.", "answer": "TEMPLATE: The answer.", "source": "user" }
|
|
88
|
+
],
|
|
89
|
+
"open": ["TEMPLATE: A question that is still open."],
|
|
90
|
+
"diagram": "flowchart LR\n Request --> Plan --> Approval --> Steps"
|
|
91
|
+
}
|
|
92
|
+
</script>
|
|
93
|
+
<script>
|
|
94
|
+
const plan = JSON.parse(document.getElementById("plan").textContent);
|
|
95
|
+
const element = (name, text, className) => {
|
|
96
|
+
const node = document.createElement(name);
|
|
97
|
+
node.textContent = text;
|
|
98
|
+
if (className) node.className = className;
|
|
99
|
+
return node;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
document.title = plan.title;
|
|
103
|
+
document.getElementById("title").textContent = plan.title;
|
|
104
|
+
document.getElementById("goal").textContent = plan.goal;
|
|
105
|
+
document.getElementById("plan-path").textContent = plan.planPath;
|
|
106
|
+
document.getElementById("updated").textContent = plan.updated;
|
|
107
|
+
document.getElementById("count-done").textContent = plan.steps.filter((step) => step.status === "done").length;
|
|
108
|
+
document.getElementById("count-left").textContent = plan.steps.filter((step) => step.status !== "done" && step.status !== "skipped").length;
|
|
109
|
+
document.getElementById("count-open").textContent = plan.open.length;
|
|
110
|
+
|
|
111
|
+
for (const step of plan.steps) {
|
|
112
|
+
const item = document.createElement("li");
|
|
113
|
+
const body = element("div", step.text);
|
|
114
|
+
if (step.note) body.append(element("span", step.note, "note"));
|
|
115
|
+
item.append(element("span", step.status, `status ${step.status}`), body);
|
|
116
|
+
document.getElementById("steps").append(item);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const decision of plan.decisions) {
|
|
120
|
+
const row = document.createElement("tr");
|
|
121
|
+
row.append(element("td", decision.question), element("td", decision.answer), element("td", decision.source));
|
|
122
|
+
document.getElementById("decisions").append(row);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const question of plan.open) {
|
|
126
|
+
document.getElementById("open").append(element("li", question));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (plan.diagram) {
|
|
130
|
+
document.getElementById("diagram-section").hidden = false;
|
|
131
|
+
document.getElementById("diagram-source").textContent = plan.diagram;
|
|
132
|
+
|
|
133
|
+
const script = document.createElement("script");
|
|
134
|
+
script.src = "https://cdn.jsdelivr.net/npm/mermaid@12.1.0/dist/mermaid.min.js";
|
|
135
|
+
script.integrity = "sha384-EbBpjO7rlR6eqZEcG7GaPpyk9H9WrMyPWX4d3KvPYltgt8Z8l0z6R56B1qP40pR4";
|
|
136
|
+
script.crossOrigin = "anonymous";
|
|
137
|
+
script.onload = async () => {
|
|
138
|
+
const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
139
|
+
window.mermaid.initialize({
|
|
140
|
+
startOnLoad: false,
|
|
141
|
+
securityLevel: "strict",
|
|
142
|
+
htmlLabels: false,
|
|
143
|
+
flowchart: { htmlLabels: false },
|
|
144
|
+
theme: dark ? "dark" : "default",
|
|
145
|
+
});
|
|
146
|
+
const { svg } = await window.mermaid.render("plan-diagram", plan.diagram);
|
|
147
|
+
const image = document.createElement("img");
|
|
148
|
+
image.alt = "Plan diagram";
|
|
149
|
+
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
150
|
+
document.getElementById("diagram").replaceChildren(image);
|
|
151
|
+
document.getElementById("diagram-note").textContent = "Diagram source:";
|
|
152
|
+
};
|
|
153
|
+
script.onerror = () => {
|
|
154
|
+
document.getElementById("diagram-note").textContent = "Mermaid did not load, so the diagram shows as source text.";
|
|
155
|
+
};
|
|
156
|
+
document.head.append(script);
|
|
157
|
+
}
|
|
158
|
+
</script>
|
|
159
|
+
</body>
|
|
160
|
+
</html>
|