@shiyuphan/follow-me 0.1.0 → 0.2.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.
- package/README.md +128 -56
- package/dist/src/anthropic.js +41 -9
- package/dist/src/cli.js +836 -82
- package/dist/src/comments.js +300 -0
- package/dist/src/git.js +103 -24
- package/dist/src/harness.js +74 -0
- package/dist/src/host.js +379 -0
- package/dist/src/install.js +131 -0
- package/dist/src/naive.js +26 -0
- package/dist/src/plan.js +281 -25
- package/dist/src/prompt.js +391 -82
- package/dist/src/server.js +710 -160
- package/dist/src/session.js +311 -104
- package/dist/src/syntax.js +199 -0
- package/dist/src/types.js +5 -2
- package/dist/ui/app.js +1703 -272
- package/dist/ui/concerns.js +165 -0
- package/dist/ui/highlight.js +221 -0
- package/dist/ui/index.html +62 -7
- package/dist/ui/live.js +184 -0
- package/dist/ui/playback.js +480 -76
- package/dist/ui/questions.js +189 -0
- package/dist/ui/style.css +975 -75
- package/dist/ui/summary.js +45 -0
- package/package.json +4 -2
- package/skills/follow-me/SKILL.md +34 -16
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { extname, join } from "node:path";
|
|
5
|
+
const LIMIT = 4;
|
|
6
|
+
const TIMEOUT_MS = 30_000;
|
|
7
|
+
/**
|
|
8
|
+
* node --check does not strip types, so .ts goes through module.stripTypeScriptTypes, which
|
|
9
|
+
* parses the whole file and throws on a syntax error. An enum or namespace parses but cannot be
|
|
10
|
+
* stripped, which is node's limit and not the file's, so that counts as parsing.
|
|
11
|
+
*/
|
|
12
|
+
const STRIP_TYPES = `
|
|
13
|
+
const { stripTypeScriptTypes } = require("node:module");
|
|
14
|
+
const { readFileSync } = require("node:fs");
|
|
15
|
+
try {
|
|
16
|
+
stripTypeScriptTypes(readFileSync(process.argv[1], "utf8"), { mode: "strip" });
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error.code === "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX") process.exit(0);
|
|
19
|
+
console.error(error.message);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}`;
|
|
22
|
+
/** Runs argv with {file} replaced; a program that is not installed skips the file. */
|
|
23
|
+
function command(argv) {
|
|
24
|
+
return (file) => run(argv.map((a) => (a === "{file}" ? file : a)), file, argv[0] ?? "");
|
|
25
|
+
}
|
|
26
|
+
function run(argv, file, what) {
|
|
27
|
+
const [program, ...rest] = argv;
|
|
28
|
+
if (program === undefined)
|
|
29
|
+
return Promise.resolve({ kind: "parses" });
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
execFile(program, rest, { timeout: TIMEOUT_MS, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
32
|
+
if (error === null)
|
|
33
|
+
return resolve({ kind: "parses" });
|
|
34
|
+
if (error.code === "ENOENT") {
|
|
35
|
+
return resolve({ kind: "skipped", reason: `${what} is not installed` });
|
|
36
|
+
}
|
|
37
|
+
const output = stderr.trim() === "" ? stdout : stderr;
|
|
38
|
+
const message = error.killed === true
|
|
39
|
+
? `checker timed out after ${TIMEOUT_MS / 1000}s`
|
|
40
|
+
: output.trim() === ""
|
|
41
|
+
? `checker exited ${error.code}`
|
|
42
|
+
: complaint(output, file);
|
|
43
|
+
resolve({ kind: "fails", message });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The line of a parser's output that names the problem. node prints `file:LINE`, the source line,
|
|
49
|
+
* a caret and then `SyntaxError: ...`; python prints a traceback ending in `SyntaxError: ...`;
|
|
50
|
+
* sh and bash print one line. The temp path never leaks into the message.
|
|
51
|
+
*/
|
|
52
|
+
export function complaint(output, file) {
|
|
53
|
+
const lines = output
|
|
54
|
+
.replaceAll(file, "file")
|
|
55
|
+
.split("\n")
|
|
56
|
+
.map((l) => l.trimEnd())
|
|
57
|
+
.filter((l) => l !== "");
|
|
58
|
+
const named = lines.find((l) => /^[A-Za-z]*Error\b/.test(l));
|
|
59
|
+
if (named === undefined)
|
|
60
|
+
return lines[0] ?? "does not parse";
|
|
61
|
+
const where = /^file:(\d+)$/.exec(lines[0] ?? "")?.[1];
|
|
62
|
+
return where === undefined ? named : `line ${where}: ${named}`;
|
|
63
|
+
}
|
|
64
|
+
const json = async (_file, text) => {
|
|
65
|
+
try {
|
|
66
|
+
JSON.parse(text);
|
|
67
|
+
return { kind: "parses" };
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71
|
+
return { kind: "fails", message: message.split("\n")[0] ?? message };
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
/** Bash scripts need bash -n; a plain .sh is held to POSIX unless its shebang names bash. */
|
|
75
|
+
const sh = command(["sh", "-n", "{file}"]);
|
|
76
|
+
const bash = command(["bash", "-n", "{file}"]);
|
|
77
|
+
const shell = (file, text) => /^#!.*\bbash\b/.test(text) || file.endsWith(".bash") ? bash(file, text) : sh(file, text);
|
|
78
|
+
const node = command(["node", "--check", "{file}"]);
|
|
79
|
+
const types = command([
|
|
80
|
+
"node",
|
|
81
|
+
"--disable-warning=ExperimentalWarning",
|
|
82
|
+
"-e",
|
|
83
|
+
STRIP_TYPES,
|
|
84
|
+
"{file}",
|
|
85
|
+
]);
|
|
86
|
+
const python = command(["python3", "-m", "py_compile", "{file}"]);
|
|
87
|
+
/**
|
|
88
|
+
* Extension to checker. A missing extension is skipped, not guessed: .jsx and .tsx need a JSX
|
|
89
|
+
* parser node does not have, and yaml has none installed.
|
|
90
|
+
*/
|
|
91
|
+
const CHECKERS = new Map([
|
|
92
|
+
[".js", node],
|
|
93
|
+
[".mjs", node],
|
|
94
|
+
[".cjs", node],
|
|
95
|
+
[".ts", types],
|
|
96
|
+
[".mts", types],
|
|
97
|
+
[".cts", types],
|
|
98
|
+
[".json", json],
|
|
99
|
+
[".sh", shell],
|
|
100
|
+
[".bash", shell],
|
|
101
|
+
[".py", python],
|
|
102
|
+
]);
|
|
103
|
+
function quote(s) {
|
|
104
|
+
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
105
|
+
}
|
|
106
|
+
/** The --syntax override: one shell command for every text file, {file} replaced by its path. */
|
|
107
|
+
function overrideChecker(template) {
|
|
108
|
+
return (file) => run(["sh", "-c", template.replaceAll("{file}", quote(file))], file, "the --syntax command");
|
|
109
|
+
}
|
|
110
|
+
async function mapLimit(items, limit, fn) {
|
|
111
|
+
const out = new Array(items.length);
|
|
112
|
+
let next = 0;
|
|
113
|
+
const worker = async () => {
|
|
114
|
+
for (let i = next++; i < items.length; i = next++) {
|
|
115
|
+
const item = items[i];
|
|
116
|
+
if (item !== undefined)
|
|
117
|
+
out[i] = await fn(item);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
function byPath(a, b) {
|
|
124
|
+
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Checks every file each commit writes. The last commit equals the tip by construction, so a
|
|
128
|
+
* file that fails there is reported as the tip's own state and is not checked in earlier commits,
|
|
129
|
+
* where the same failure would be blamed on the split.
|
|
130
|
+
*/
|
|
131
|
+
export async function checkSyntax(plan, opts) {
|
|
132
|
+
const last = plan.commits.length - 1;
|
|
133
|
+
const jobs = plan.commits.flatMap((c) => c.changes.flatMap((ch) => ch.op === "write" ? [{ commit: c.index, path: ch.path, bytes: ch.bytes }] : []));
|
|
134
|
+
// node prints the resolved path, so the dir is resolved too for the message to find it.
|
|
135
|
+
const dir = await realpath(await mkdtemp(join(tmpdir(), "follow-me-syntax-")));
|
|
136
|
+
try {
|
|
137
|
+
const cache = new Map();
|
|
138
|
+
let serial = 0;
|
|
139
|
+
const outcomeOf = (job) => {
|
|
140
|
+
const ext = extname(job.path).toLowerCase();
|
|
141
|
+
const checker = opts.override === undefined ? CHECKERS.get(ext) : overrideChecker(opts.override);
|
|
142
|
+
if (checker === undefined) {
|
|
143
|
+
return Promise.resolve({
|
|
144
|
+
kind: "skipped",
|
|
145
|
+
reason: `no checker for ${ext === "" ? "files without an extension" : ext}`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const key = `${ext}\0${job.bytes}`;
|
|
149
|
+
const known = cache.get(key);
|
|
150
|
+
if (known !== undefined)
|
|
151
|
+
return known;
|
|
152
|
+
const file = join(dir, `f${serial++}${ext}`);
|
|
153
|
+
const fresh = writeFile(file, Buffer.from(job.bytes, "latin1")).then(() => checker(file, job.bytes));
|
|
154
|
+
cache.set(key, fresh);
|
|
155
|
+
return fresh;
|
|
156
|
+
};
|
|
157
|
+
const tipJobs = jobs.filter((j) => j.commit === last);
|
|
158
|
+
const tipOutcomes = await mapLimit(tipJobs, LIMIT, outcomeOf);
|
|
159
|
+
const tip = [];
|
|
160
|
+
const broken = new Set();
|
|
161
|
+
tipJobs.forEach((job, i) => {
|
|
162
|
+
const outcome = tipOutcomes[i];
|
|
163
|
+
if (outcome?.kind !== "fails")
|
|
164
|
+
return;
|
|
165
|
+
tip.push({ commit: job.commit, path: job.path, message: outcome.message });
|
|
166
|
+
broken.add(job.path);
|
|
167
|
+
});
|
|
168
|
+
const rest = jobs.filter((j) => j.commit !== last && !broken.has(j.path));
|
|
169
|
+
const outcomes = await mapLimit(rest, LIMIT, outcomeOf);
|
|
170
|
+
const issues = [];
|
|
171
|
+
const skipped = new Map();
|
|
172
|
+
let checked = 0;
|
|
173
|
+
const all = [
|
|
174
|
+
...tipJobs.map((j, i) => [j, tipOutcomes[i]]),
|
|
175
|
+
...rest.map((j, i) => [j, outcomes[i]]),
|
|
176
|
+
];
|
|
177
|
+
for (const [job, outcome] of all) {
|
|
178
|
+
if (outcome === undefined)
|
|
179
|
+
continue;
|
|
180
|
+
if (outcome.kind === "skipped")
|
|
181
|
+
skipped.set(job.path, outcome.reason);
|
|
182
|
+
else
|
|
183
|
+
checked++;
|
|
184
|
+
if (outcome.kind === "fails" && job.commit !== last) {
|
|
185
|
+
issues.push({ commit: job.commit, path: job.path, message: outcome.message });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
issues.sort((a, b) => a.commit - b.commit || byPath(a, b));
|
|
189
|
+
return {
|
|
190
|
+
issues,
|
|
191
|
+
tip,
|
|
192
|
+
skipped: [...skipped].map(([path, reason]) => ({ path, reason })).sort(byPath),
|
|
193
|
+
checked,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
await rm(dir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
}
|
package/dist/src/types.js
CHANGED
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
* project every commit, prove the last one equals the tip. Pure.
|
|
9
9
|
* git.ts the only module that spawns git: load a Diff, write a branch from a ValidPlan.
|
|
10
10
|
* session.ts the .follow-me/<slug>/ layout: session anchor, plans, reviews, draft.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* comments.ts the wire formats of review state: comments, replies, concern decisions, the
|
|
12
|
+
* review file, the autosaved draft and the server's events. Pure parsers.
|
|
13
|
+
* prompt.ts the planner prompt for an agent, the feedback and the consolidated report.
|
|
14
|
+
* server.ts the review server: a registry of hosted sessions, their JSON API, and ui/.
|
|
15
|
+
* host.ts the one server per user: its record in the state dir, attach, start, stop.
|
|
13
16
|
* cli.ts argv -> one call into the modules above.
|
|
14
17
|
*/
|
|
15
18
|
export {};
|