@levistudio/redline 0.1.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/CHANGELOG.md +37 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/ROADMAP.md +39 -0
- package/SECURITY.md +33 -0
- package/bin/redline.cjs +61 -0
- package/package.json +61 -0
- package/scripts/install-skill.sh +78 -0
- package/skills/redline-review/SKILL.md +102 -0
- package/src/agent.ts +283 -0
- package/src/cli.ts +332 -0
- package/src/client/cards.ts +385 -0
- package/src/client/diff.ts +100 -0
- package/src/client/firstRunBanner.ts +26 -0
- package/src/client/lib.ts +299 -0
- package/src/client/main.ts +119 -0
- package/src/client/render.ts +413 -0
- package/src/client/selection.ts +253 -0
- package/src/client/sse.ts +179 -0
- package/src/client/state.ts +56 -0
- package/src/client/styles.css +994 -0
- package/src/contextBlock.ts +16 -0
- package/src/diff.ts +166 -0
- package/src/parseReply.ts +115 -0
- package/src/pickModel.ts +38 -0
- package/src/promptEnvelope.ts +58 -0
- package/src/render.ts +83 -0
- package/src/resolve.ts +290 -0
- package/src/server-page.ts +119 -0
- package/src/server.ts +634 -0
- package/src/sidecar.ts +190 -0
package/src/sidecar.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
2
|
+
import { closeSync, existsSync, openSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import lockfile from "proper-lockfile";
|
|
5
|
+
|
|
6
|
+
export interface ThreadEntry {
|
|
7
|
+
role: "human" | "agent";
|
|
8
|
+
name?: string;
|
|
9
|
+
message: string;
|
|
10
|
+
at: string;
|
|
11
|
+
// Agent replies carry a verdict on whether the comment, once resolved,
|
|
12
|
+
// implies an edit to the document. Used to decide whether the round-level
|
|
13
|
+
// action defaults to "Revise" or "Accept as-is". Only set on agent entries.
|
|
14
|
+
requires_revision?: boolean;
|
|
15
|
+
revision_reason?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Latest agent verdict on a comment thread:
|
|
19
|
+
// - 'revise' → an agent reply set requires_revision: true
|
|
20
|
+
// - 'accept' → an agent reply set requires_revision: false
|
|
21
|
+
// - null → no agent reply yet (treat as accept per UX spec; the human
|
|
22
|
+
// resolved unilaterally so they're saying "doesn't matter")
|
|
23
|
+
export function latestVerdict(comment: Comment): "revise" | "accept" | null {
|
|
24
|
+
for (let i = comment.thread.length - 1; i >= 0; i--) {
|
|
25
|
+
const e = comment.thread[i];
|
|
26
|
+
if (e.role !== "agent") continue;
|
|
27
|
+
if (typeof e.requires_revision !== "boolean") continue;
|
|
28
|
+
return e.requires_revision ? "revise" : "accept";
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Comment {
|
|
34
|
+
id: string;
|
|
35
|
+
quote: string;
|
|
36
|
+
context_before: string;
|
|
37
|
+
context_after: string;
|
|
38
|
+
thread: ThreadEntry[];
|
|
39
|
+
resolved: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Round {
|
|
43
|
+
round: number;
|
|
44
|
+
started_at: string;
|
|
45
|
+
submitted_at: string | null; // human clicked "Submit for review" — agent should respond
|
|
46
|
+
agent_replied_at: string | null; // agent posted replies — human should review
|
|
47
|
+
resolved_at: string | null; // human accepted — agent should revise the document
|
|
48
|
+
comments: Comment[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Sidecar {
|
|
52
|
+
file: string;
|
|
53
|
+
context?: string;
|
|
54
|
+
rounds: Round[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sidecarPath(filePath: string): string {
|
|
58
|
+
const dir = path.join(path.dirname(filePath), ".review");
|
|
59
|
+
const base = path.basename(filePath) + ".json";
|
|
60
|
+
return path.join(dir, base);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function loadSidecar(filePath: string): Promise<Sidecar> {
|
|
64
|
+
const sp = sidecarPath(filePath);
|
|
65
|
+
if (existsSync(sp)) {
|
|
66
|
+
const raw = await readFile(sp, "utf-8");
|
|
67
|
+
// withSidecar may touch an empty sidecar file before the first save
|
|
68
|
+
// (lockfile needs a target that exists); treat that as the empty shape.
|
|
69
|
+
if (raw.trim() === "") {
|
|
70
|
+
return { file: path.basename(filePath), rounds: [] };
|
|
71
|
+
}
|
|
72
|
+
return JSON.parse(raw) as Sidecar;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
file: path.basename(filePath),
|
|
76
|
+
rounds: [],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function saveSidecar(
|
|
81
|
+
filePath: string,
|
|
82
|
+
sidecar: Sidecar
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
const sp = sidecarPath(filePath);
|
|
85
|
+
await mkdir(path.dirname(sp), { recursive: true });
|
|
86
|
+
await writeFile(sp, JSON.stringify(sidecar, null, 2), "utf-8");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Two layers of mutex around the load → mutate → save cycle:
|
|
90
|
+
//
|
|
91
|
+
// 1. Per-process queue (in-memory) — fast path that serializes calls inside
|
|
92
|
+
// one redline process. The previous version used only this; two POSTs
|
|
93
|
+
// against the same file would interleave their load/save pair without it.
|
|
94
|
+
//
|
|
95
|
+
// 2. Per-file lock (proper-lockfile, on `<sidecar>.lock`) — slow path that
|
|
96
|
+
// serializes calls across processes. Without this, two `redline` instances
|
|
97
|
+
// on the same file (two terminals, a forgotten background instance, a
|
|
98
|
+
// skill invocation while a manual run is open) silently trample each
|
|
99
|
+
// other's writes. The in-memory queue inside each process never sees the
|
|
100
|
+
// other process exists.
|
|
101
|
+
//
|
|
102
|
+
// Layering matters: every transaction acquires the file lock, but only one
|
|
103
|
+
// in-flight per process competes for it. Without the in-memory queue, a
|
|
104
|
+
// flurry of POSTs in one process would all contend on the file lock and
|
|
105
|
+
// serialize through filesystem operations (slow, can stall on macOS).
|
|
106
|
+
const sidecarLocks = new Map<string, Promise<unknown>>();
|
|
107
|
+
|
|
108
|
+
function lockTargetFor(filePath: string): string {
|
|
109
|
+
// proper-lockfile creates `<file>.lock` next to the target. We lock the
|
|
110
|
+
// sidecar JSON itself, not the doc, since the sidecar is the contended
|
|
111
|
+
// resource. proper-lockfile requires the target to exist; touch it if
|
|
112
|
+
// it's the first transaction on a brand-new file.
|
|
113
|
+
return sidecarPath(filePath);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function ensureLockTarget(filePath: string): Promise<string> {
|
|
117
|
+
const sp = lockTargetFor(filePath);
|
|
118
|
+
await mkdir(path.dirname(sp), { recursive: true });
|
|
119
|
+
if (!existsSync(sp)) {
|
|
120
|
+
// Touch — empty file is fine, loadSidecar treats missing-or-empty alike.
|
|
121
|
+
closeSync(openSync(sp, "a"));
|
|
122
|
+
}
|
|
123
|
+
return sp;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run `fn` against the sidecar with a per-file mutex held across the
|
|
128
|
+
* load → mutate → save cycle. The mutator may return a value (passed through
|
|
129
|
+
* to the caller) and/or `false` to skip the save (e.g. when validation fails
|
|
130
|
+
* before any mutation happened — avoids a redundant disk write).
|
|
131
|
+
*
|
|
132
|
+
* Lock scope spans both processes (file lock) and intra-process callers
|
|
133
|
+
* (in-memory queue), so concurrent redline instances on the same file
|
|
134
|
+
* serialize correctly.
|
|
135
|
+
*/
|
|
136
|
+
export async function withSidecar<T>(
|
|
137
|
+
filePath: string,
|
|
138
|
+
fn: (sidecar: Sidecar) => T | Promise<T>
|
|
139
|
+
): Promise<T> {
|
|
140
|
+
const key = path.resolve(filePath);
|
|
141
|
+
const prev = sidecarLocks.get(key) ?? Promise.resolve();
|
|
142
|
+
const next = prev.then(async () => {
|
|
143
|
+
const lockTarget = await ensureLockTarget(filePath);
|
|
144
|
+
// retries.forever: true is a deliberate choice — withSidecar callers
|
|
145
|
+
// expect to succeed eventually. Inter-process contention should be rare
|
|
146
|
+
// (two redline instances on the same file is itself unusual); when it
|
|
147
|
+
// happens, waiting is the right behavior, not failing.
|
|
148
|
+
const release = await lockfile.lock(lockTarget, {
|
|
149
|
+
retries: { retries: 30, factor: 1.5, minTimeout: 50, maxTimeout: 1000 },
|
|
150
|
+
stale: 30_000, // a process holding the lock >30s is presumed dead
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
const sidecar = await loadSidecar(filePath);
|
|
154
|
+
const result = await fn(sidecar);
|
|
155
|
+
// Skip the save if the mutator explicitly returned false. Useful for
|
|
156
|
+
// validate-only paths that bail before mutating; keeps the lock scope
|
|
157
|
+
// honest (we still serialized) without the wasted write.
|
|
158
|
+
if ((result as unknown) !== false) {
|
|
159
|
+
await saveSidecar(filePath, sidecar);
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
} finally {
|
|
163
|
+
await release().catch(() => {});
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
// Catch errors so one failed mutator doesn't poison the queue for the next
|
|
167
|
+
// caller. The original `next` promise still rejects for the current caller.
|
|
168
|
+
sidecarLocks.set(key, next.catch(() => {}));
|
|
169
|
+
return next;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function activeRound(sidecar: Sidecar): Round | null {
|
|
173
|
+
return sidecar.rounds.find((r) => r.resolved_at === null) ?? null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function getOrCreateActiveRound(sidecar: Sidecar): Round {
|
|
177
|
+
const existing = activeRound(sidecar);
|
|
178
|
+
if (existing) return existing;
|
|
179
|
+
|
|
180
|
+
const round: Round = {
|
|
181
|
+
round: sidecar.rounds.length + 1,
|
|
182
|
+
started_at: new Date().toISOString(),
|
|
183
|
+
submitted_at: null,
|
|
184
|
+
agent_replied_at: null,
|
|
185
|
+
resolved_at: null,
|
|
186
|
+
comments: [],
|
|
187
|
+
};
|
|
188
|
+
sidecar.rounds.push(round);
|
|
189
|
+
return round;
|
|
190
|
+
}
|