rippletide-package 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/PUBLISHING.md +144 -0
- package/README.md +44 -0
- package/bin/rippletide.js +27 -0
- package/counter/README.md +37 -0
- package/counter/frontend/window.jxa +601 -0
- package/counter/src/cli.js +22 -0
- package/counter/src/counter.js +165 -0
- package/counter/src/proxy.js +243 -0
- package/package.json +64 -0
- package/reviewer/.env.example +5 -0
- package/reviewer/README.md +137 -0
- package/reviewer/bin/rippletide-review.js +16 -0
- package/reviewer/bin/rippletide.js +8 -0
- package/reviewer/codex/rippletide-reviewer/README.md +28 -0
- package/reviewer/codex/rippletide-reviewer/scripts/rippletide-codex +16 -0
- package/reviewer/codex/rippletide-reviewer/scripts/rippletide-codex.mjs +24 -0
- package/reviewer/codex/rippletide-reviewer/scripts/rippletide-reviewer +37 -0
- package/reviewer/src/connect/evidence.js +235 -0
- package/reviewer/src/integrations/cli/rippletide.js +1595 -0
- package/reviewer/src/integrations/codex/prepare.js +277 -0
- package/tide/README.md +128 -0
- package/tide/bin/tide.js +4 -0
- package/tide/examples/no-file-writes/POLICY.md +3 -0
- package/tide/src/cli.js +173 -0
- package/tide/src/codex.js +65 -0
- package/tide/src/compile.js +193 -0
- package/tide/src/credentials.js +41 -0
- package/tide/src/engine.js +115 -0
- package/tide/src/expr.js +260 -0
- package/tide/src/hook.js +71 -0
- package/tide/src/ontology.js +28 -0
- package/tide/src/schema.js +87 -0
|
@@ -0,0 +1,1595 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_EVIDENCE_BUDGET,
|
|
10
|
+
collectAgentContext,
|
|
11
|
+
redactSecrets,
|
|
12
|
+
} from "../../connect/evidence.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MODEL = "gpt-5.5";
|
|
15
|
+
const REVIEWER_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
16
|
+
const COLOR_ENABLED = !process.env.NO_COLOR
|
|
17
|
+
&& process.env.TERM !== "dumb"
|
|
18
|
+
&& Boolean(process.stdout.isTTY || process.stderr.isTTY);
|
|
19
|
+
|
|
20
|
+
function color(code, value) {
|
|
21
|
+
return COLOR_ENABLED ? `\x1b[${code}m${value}\x1b[0m` : value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const style = {
|
|
25
|
+
bold: (value) => color(1, value),
|
|
26
|
+
dim: (value) => color(2, value),
|
|
27
|
+
gray: (value) => color(90, value),
|
|
28
|
+
green: (value) => color(32, value),
|
|
29
|
+
magenta: (value) => color(35, value),
|
|
30
|
+
red: (value) => color(31, value),
|
|
31
|
+
yellow: (value) => color(33, value),
|
|
32
|
+
cyan: (value) => color(36, value),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const STATUS_WORDS = [
|
|
36
|
+
"Rippling",
|
|
37
|
+
"Tide-mapping",
|
|
38
|
+
"Evidence-sifting",
|
|
39
|
+
"Trace-reading",
|
|
40
|
+
"Signal-finding",
|
|
41
|
+
"Flow-checking",
|
|
42
|
+
"Context-packing",
|
|
43
|
+
"Boundary-checking",
|
|
44
|
+
"Proof-matching",
|
|
45
|
+
"Drift-spotting",
|
|
46
|
+
"Finding-shaping",
|
|
47
|
+
"Edit-tuning",
|
|
48
|
+
"Action-routing",
|
|
49
|
+
"Ship-checking",
|
|
50
|
+
"Tidewalking",
|
|
51
|
+
"Rippletiding",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const INSPECTOR_INSTRUCTIONS = `You are Rippletide's agent systems inspector.
|
|
55
|
+
|
|
56
|
+
Your job is to read a local evidence pack for an AI agent and produce a precise, neutral Agent Dossier.
|
|
57
|
+
|
|
58
|
+
Important:
|
|
59
|
+
- The code that collected the evidence did not decide what the agent is. You must infer the agent from the evidence.
|
|
60
|
+
- Work for any type of agent: coding agent, research agent, business-system agent, support agent, browser agent, workflow agent, MCP server, prompt-only agent, Python/TypeScript/Go/Rust/etc.
|
|
61
|
+
- Inspection is extraction, not review. Do not judge whether the agent is good, safe, risky, shippable, or well-designed.
|
|
62
|
+
- Do not produce findings, recommendations, fixes, tests to add, or "why this matters" commentary.
|
|
63
|
+
- Do not use review language such as "risk", "risky", "dangerous", "high-impact", "should", "would not ship", "missing guardrail", "not safe", or "smallest proof needed" unless those exact words appear in the evidence. If they appear in evidence, make clear they are declared by the source.
|
|
64
|
+
- Prefer "Not shown in local evidence" over guessing.
|
|
65
|
+
- Use neutral labels: "Observed", "Declared", "Inferred from local evidence", and "Not shown in local evidence".
|
|
66
|
+
- Cite concrete claims with file:line references from the evidence pack.
|
|
67
|
+
- List actual tools, actions, entrypoints, env vars, data sources, tests, evals, runtime paths, policies, and gates when shown.
|
|
68
|
+
- If a capability is delegated to another service or runtime, say what is visible locally and what remains opaque from local evidence.
|
|
69
|
+
- Use natural builder language, but keep it factual and precise.
|
|
70
|
+
- Do not add generic security or AI safety advice.
|
|
71
|
+
- Do not repeat the same fact across sections unless it is needed for that section.
|
|
72
|
+
|
|
73
|
+
Accuracy rules:
|
|
74
|
+
- A prompt, README, or manifest declaration is not runtime proof. Label it as "Declared".
|
|
75
|
+
- Code paths, routes, tool definitions, tests, and fixtures are local evidence. Label them as "Observed".
|
|
76
|
+
- If a test/eval exists, say only what it directly exercises. Do not infer wider coverage.
|
|
77
|
+
- If a line number is uncertain, cite the nearest file/line shown in the evidence pack and keep the claim narrow.
|
|
78
|
+
- If the evidence pack omits a file because of budget, do not claim the repo lacks that behavior. Say "Not shown in the included local evidence."
|
|
79
|
+
|
|
80
|
+
Return exactly this markdown shape:
|
|
81
|
+
|
|
82
|
+
Agent Dossier
|
|
83
|
+
|
|
84
|
+
1. Identity
|
|
85
|
+
- Agent:
|
|
86
|
+
- Repo/folder:
|
|
87
|
+
- Type:
|
|
88
|
+
- Interface:
|
|
89
|
+
- Runtime/framework:
|
|
90
|
+
- External systems:
|
|
91
|
+
- Main files:
|
|
92
|
+
- Scan scope:
|
|
93
|
+
|
|
94
|
+
2. Intended Job
|
|
95
|
+
- Declared job:
|
|
96
|
+
- Inferred job from local evidence:
|
|
97
|
+
- Users/requesters served:
|
|
98
|
+
- Success signal or final output:
|
|
99
|
+
- Expected actions:
|
|
100
|
+
- Declared limits or non-goals:
|
|
101
|
+
- Unknowns:
|
|
102
|
+
|
|
103
|
+
3. Instructions
|
|
104
|
+
- Instruction sources:
|
|
105
|
+
- System/developer prompt summary:
|
|
106
|
+
- Rules as written:
|
|
107
|
+
- Output requirements:
|
|
108
|
+
- Tool-use instructions:
|
|
109
|
+
- Escalation/refusal/approval instructions:
|
|
110
|
+
- Ambiguous or conflicting instructions shown:
|
|
111
|
+
|
|
112
|
+
4. Capabilities
|
|
113
|
+
- Tool surface:
|
|
114
|
+
- Tools/actions:
|
|
115
|
+
- Read capability families:
|
|
116
|
+
- Write capability families:
|
|
117
|
+
- Delete/send/deploy/external-mutation actions:
|
|
118
|
+
- External services called:
|
|
119
|
+
- Opaque delegated capabilities:
|
|
120
|
+
- Unknowns:
|
|
121
|
+
|
|
122
|
+
5. Permissions And Credentials
|
|
123
|
+
- Required env vars:
|
|
124
|
+
- Credential-backed systems:
|
|
125
|
+
- User/channel identity:
|
|
126
|
+
- Production vs sandbox hints:
|
|
127
|
+
- Scope/role hints:
|
|
128
|
+
- Permission checks shown:
|
|
129
|
+
- Permission checks not shown:
|
|
130
|
+
|
|
131
|
+
6. Runtime Behavior
|
|
132
|
+
- Entry path:
|
|
133
|
+
- Normal execution path:
|
|
134
|
+
- Tool execution path:
|
|
135
|
+
- Fallback/error path:
|
|
136
|
+
- Retry/timeout behavior:
|
|
137
|
+
- Success/failure states:
|
|
138
|
+
- How tool calls are recorded:
|
|
139
|
+
- How final output is produced:
|
|
140
|
+
|
|
141
|
+
7. Memory And Context
|
|
142
|
+
- Input context:
|
|
143
|
+
- Files/data sources it reads:
|
|
144
|
+
- Retrieval/search behavior:
|
|
145
|
+
- User/session memory:
|
|
146
|
+
- Cached or stored state:
|
|
147
|
+
- Sensitive data surfaces:
|
|
148
|
+
- Context handling not shown:
|
|
149
|
+
|
|
150
|
+
8. Policies And Gates
|
|
151
|
+
- Policy sources:
|
|
152
|
+
- Approval/human review instructions:
|
|
153
|
+
- Identity/auth checks shown:
|
|
154
|
+
- Deny/refusal instructions:
|
|
155
|
+
- Redaction/privacy handling shown:
|
|
156
|
+
- Rate/abuse limits shown:
|
|
157
|
+
- Gate enforcement code shown:
|
|
158
|
+
- Gate enforcement not shown:
|
|
159
|
+
|
|
160
|
+
9. Pre-Ship Proof
|
|
161
|
+
- Tests:
|
|
162
|
+
- Evals:
|
|
163
|
+
- Fixtures/mock traces:
|
|
164
|
+
- Assertions:
|
|
165
|
+
- Runtime traces/log examples:
|
|
166
|
+
- Behavior directly exercised by local proof:
|
|
167
|
+
- Behavior declared but not exercised locally:
|
|
168
|
+
- Unknowns:
|
|
169
|
+
|
|
170
|
+
10. Business/System Footprint
|
|
171
|
+
- Primary output:
|
|
172
|
+
- External systems touched:
|
|
173
|
+
- Data or records touched:
|
|
174
|
+
- User-facing surfaces:
|
|
175
|
+
- Downstream dependencies:
|
|
176
|
+
- Observable side effects:
|
|
177
|
+
- Open factual questions:
|
|
178
|
+
|
|
179
|
+
Reviewer note:
|
|
180
|
+
This is neutral extraction only. It reads the local agent system and builds a dossier from included evidence; it does not judge the agent or claim live runtime proof unless traces/logs are present in the evidence pack.`;
|
|
181
|
+
|
|
182
|
+
const REVIEWER_INSTRUCTIONS = `You are Rippletide's agent reviewer.
|
|
183
|
+
|
|
184
|
+
You receive two inputs:
|
|
185
|
+
1. An internal neutral Agent Dossier produced by inspection.
|
|
186
|
+
2. The local evidence pack with file:line excerpts.
|
|
187
|
+
|
|
188
|
+
Your job is to reread the evidence pack with the dossier as context and produce the user-facing review.
|
|
189
|
+
The builder should see useful findings, not the dossier.
|
|
190
|
+
|
|
191
|
+
What makes a good finding:
|
|
192
|
+
- It is specific to this agent, not generic AI/security advice.
|
|
193
|
+
- It connects the agent's intended job to an expected action.
|
|
194
|
+
- It identifies a concrete mismatch, missing proof, unclear gate, or behavior boundary that a builder would care about before shipping.
|
|
195
|
+
- It is backed by exact file:line evidence.
|
|
196
|
+
- It includes the smallest next proof or fix.
|
|
197
|
+
- It sounds like a senior teammate saying: "your agent says X, but the code can do Y."
|
|
198
|
+
|
|
199
|
+
Hard rules:
|
|
200
|
+
- Verify the dossier against the evidence pack. If they disagree, trust the evidence pack.
|
|
201
|
+
- Do not repeat obvious facts like "this agent uses Slack" unless that fact is part of a finding.
|
|
202
|
+
- Do not create a finding just because something is "not shown"; tie it to an expected action that the agent actually performs or declares.
|
|
203
|
+
- Do not invent runtime proof. Local code, README, manifest, fixtures, and tests are not live traces unless the evidence explicitly shows traces.
|
|
204
|
+
- Do not include generic items such as "add tests", "add logging", "validate inputs", or "secure credentials" unless they are grounded in this agent's actual behavior.
|
|
205
|
+
- Use natural builder language. No compliance phrasing, no "shipping risks", no "mutation boundaries", no audit-report tone.
|
|
206
|
+
- Return at most 3 findings. A fourth finding is noise unless it is clearly more important than one of the first three.
|
|
207
|
+
- Keep the output short. Review summary: one sentence. Each finding: at most 100 words.
|
|
208
|
+
- Evidence must be compact: at most 4 file:line references per finding, inline on one line.
|
|
209
|
+
- If there are no strong findings, say "No strong findings from local evidence" and explain the missing evidence in 2 bullets.
|
|
210
|
+
|
|
211
|
+
Return exactly this markdown shape:
|
|
212
|
+
|
|
213
|
+
Rippletide Review
|
|
214
|
+
|
|
215
|
+
Agent:
|
|
216
|
+
Found: <N> things worth checking before ship.
|
|
217
|
+
|
|
218
|
+
Finding 1: <plain-language title>
|
|
219
|
+
- What your agent says:
|
|
220
|
+
- What the code can do:
|
|
221
|
+
- Explanation:
|
|
222
|
+
- Evidence:
|
|
223
|
+
- Suggested action:
|
|
224
|
+
|
|
225
|
+
Finding 2: <plain-language title>
|
|
226
|
+
- What your agent says:
|
|
227
|
+
- What the code can do:
|
|
228
|
+
- Explanation:
|
|
229
|
+
- Evidence:
|
|
230
|
+
- Suggested action:
|
|
231
|
+
|
|
232
|
+
Only include as many findings as are actually useful.`;
|
|
233
|
+
|
|
234
|
+
const ACTION_INSTRUCTIONS = `You are Rippletide's action planner for agent builders.
|
|
235
|
+
|
|
236
|
+
You receive:
|
|
237
|
+
1. The user-facing Rippletide finding.
|
|
238
|
+
2. The full user-facing review.
|
|
239
|
+
3. The internal neutral Agent Dossier.
|
|
240
|
+
4. The local evidence pack.
|
|
241
|
+
5. Raw source excerpts for the files most relevant to the finding.
|
|
242
|
+
6. Optional builder comments from a previous proposal.
|
|
243
|
+
|
|
244
|
+
Your job is to propose the smallest useful action for this one finding.
|
|
245
|
+
|
|
246
|
+
The builder is in a coding-agent style loop. They want to accept, refuse, or comment on a proposed fix. Your response must make that easy.
|
|
247
|
+
|
|
248
|
+
Hard rules:
|
|
249
|
+
- Do not silently broaden scope beyond the finding.
|
|
250
|
+
- Do not rewrite architecture.
|
|
251
|
+
- Do not make cosmetic/doc-only changes unless the finding itself is documentation mismatch.
|
|
252
|
+
- Do not patch generated files, lock files, vendored code, or dependency snapshots.
|
|
253
|
+
- If the finding needs a product decision, external account, live runtime proof, credentials, or a test environment you cannot infer locally, return NO_PATCH and give the smallest manual check instead.
|
|
254
|
+
- If the evidence is weak or the finding is only a suspicion, return NO_PATCH and say what to inspect before changing code.
|
|
255
|
+
- If a small patch is appropriate, return a unified diff that can be applied from the scanned agent repo root with git apply.
|
|
256
|
+
- Patch paths must be relative to the scanned agent repo root.
|
|
257
|
+
- In patch hunks, use the raw source excerpts. Do not include line numbers from the evidence pack.
|
|
258
|
+
- Make patch hunks small and make hunk line counts correct.
|
|
259
|
+
- The patch should change as few files and lines as possible.
|
|
260
|
+
- Do not invent APIs, dependencies, tests, or files that are not supported by the local evidence.
|
|
261
|
+
- If the fix can be proven locally without network, credentials, or side effects, include the exact safe command in "Proof command". Otherwise use NO_COMMAND.
|
|
262
|
+
- "Proof type" must be behavior when the command exercises the changed agent behavior through a test/eval/demo assertion. Syntax/type/lint commands are code checks, not behavior proof.
|
|
263
|
+
|
|
264
|
+
Return exactly this markdown shape:
|
|
265
|
+
|
|
266
|
+
Rippletide Action
|
|
267
|
+
|
|
268
|
+
Finding: <same finding title>
|
|
269
|
+
Confidence: high|medium|low
|
|
270
|
+
|
|
271
|
+
Action:
|
|
272
|
+
- Proof:
|
|
273
|
+
- Proof type: behavior|code|manual
|
|
274
|
+
- Proof command: <npm test | bun test | bun test file.test.ts | python3 -m pytest | python3 -m unittest discover | node --test | npm run check | node --check file.js | python3 -m py_compile file.py | NO_COMMAND>
|
|
275
|
+
- Pass:
|
|
276
|
+
- Fail:
|
|
277
|
+
- Smallest fix:
|
|
278
|
+
- Files:
|
|
279
|
+
|
|
280
|
+
Patch:
|
|
281
|
+
\`\`\`diff
|
|
282
|
+
<unified diff, or NO_PATCH>
|
|
283
|
+
\`\`\`
|
|
284
|
+
|
|
285
|
+
Notes:
|
|
286
|
+
- <one or two short notes, or "None">
|
|
287
|
+
`;
|
|
288
|
+
|
|
289
|
+
function usage() {
|
|
290
|
+
return `Rippletide Reviewer
|
|
291
|
+
|
|
292
|
+
Usage:
|
|
293
|
+
rippletide review [path] [--evidence-budget 140000] [--fix]
|
|
294
|
+
rippletide inspect [path] [--evidence-budget 140000]
|
|
295
|
+
npm run inspect -- [path]
|
|
296
|
+
npm run review -- [path]
|
|
297
|
+
|
|
298
|
+
Example:
|
|
299
|
+
npx rippletide review
|
|
300
|
+
npx rippletide review ../my-agent
|
|
301
|
+
rippletide review ../my-agent
|
|
302
|
+
rippletide review ../my-agent --fix
|
|
303
|
+
rippletide inspect ../my-agent
|
|
304
|
+
|
|
305
|
+
Interactive fix mode:
|
|
306
|
+
--fix shows a short findings menu first, then proposes an action for the finding you choose.
|
|
307
|
+
Diffs are hidden until you ask to view them.
|
|
308
|
+
Accepted edits are applied locally and followed by a local proof attempt.
|
|
309
|
+
All proposals and proof reports are saved under .rippletide/actions/.
|
|
310
|
+
|
|
311
|
+
Environment:
|
|
312
|
+
Current local reviewer mode requires OPENAI_API_KEY.
|
|
313
|
+
Hosted reviewer mode will remove local model key setup.
|
|
314
|
+
`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function getOptionValue(args, name) {
|
|
318
|
+
const equalsPrefix = `${name}=`;
|
|
319
|
+
const inline = args.find((arg) => arg.startsWith(equalsPrefix));
|
|
320
|
+
if (inline) return inline.slice(equalsPrefix.length);
|
|
321
|
+
|
|
322
|
+
const index = args.indexOf(name);
|
|
323
|
+
if (index >= 0 && args[index + 1] && !args[index + 1].startsWith("-")) {
|
|
324
|
+
return args[index + 1];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function hasFlag(args, name) {
|
|
331
|
+
return args.includes(name);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function displayPath(filePath) {
|
|
335
|
+
const relative = path.relative(process.cwd(), filePath);
|
|
336
|
+
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
337
|
+
return relative;
|
|
338
|
+
}
|
|
339
|
+
return filePath;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function statusEnabled() {
|
|
343
|
+
return Boolean(process.stderr.isTTY) && process.env.CI !== "true";
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function elapsedLabel(startedAt) {
|
|
347
|
+
const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
|
348
|
+
return `${seconds}s`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function withStatus(label, task, options = {}) {
|
|
352
|
+
const words = options.words ?? STATUS_WORDS;
|
|
353
|
+
const startedAt = Date.now();
|
|
354
|
+
const frames = ["-", "\\", "|", "/"];
|
|
355
|
+
let frameIndex = 0;
|
|
356
|
+
let interval = null;
|
|
357
|
+
|
|
358
|
+
const doneLabel = (result) => {
|
|
359
|
+
if (typeof options.done === "function") return options.done(result);
|
|
360
|
+
return options.done ?? label;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
if (!statusEnabled()) {
|
|
364
|
+
console.error(`${style.bold(style.cyan("Rippletide"))} ${style.cyan(`${words[0]}...`)}`);
|
|
365
|
+
try {
|
|
366
|
+
const result = await task();
|
|
367
|
+
console.error(`${style.green("[ok]")} Rippletide ${doneLabel(result)}`);
|
|
368
|
+
return result;
|
|
369
|
+
} catch (error) {
|
|
370
|
+
console.error(`${style.red("[error]")} Rippletide ${label}`);
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const render = () => {
|
|
376
|
+
const frame = frames[frameIndex % frames.length];
|
|
377
|
+
const word = words[Math.floor((Date.now() - startedAt) / 1800) % words.length];
|
|
378
|
+
frameIndex += 1;
|
|
379
|
+
process.stderr.write(`\r\x1b[K${style.cyan(frame)} ${style.bold(style.cyan("Rippletide"))} ${style.bold(`${word}...`)} ${style.dim(elapsedLabel(startedAt))}`);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
render();
|
|
383
|
+
interval = setInterval(render, 140);
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const result = await task();
|
|
387
|
+
clearInterval(interval);
|
|
388
|
+
process.stderr.write(`\r\x1b[K${style.green("[ok]")} Rippletide ${doneLabel(result)} ${style.dim(elapsedLabel(startedAt))}\n`);
|
|
389
|
+
return result;
|
|
390
|
+
} catch (error) {
|
|
391
|
+
clearInterval(interval);
|
|
392
|
+
process.stderr.write(`\r\x1b[K${style.red("[error]")} Rippletide ${label} ${style.dim(elapsedLabel(startedAt))}\n`);
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function terminalWidth() {
|
|
398
|
+
return Math.min(Math.max(process.stdout.columns || 100, 72), 120);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function wrapWords(value, width) {
|
|
402
|
+
const words = String(value).replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
|
|
403
|
+
if (!words.length) return [""];
|
|
404
|
+
|
|
405
|
+
const lines = [];
|
|
406
|
+
let line = "";
|
|
407
|
+
|
|
408
|
+
for (const word of words) {
|
|
409
|
+
if (!line) {
|
|
410
|
+
line = word;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (line.length + word.length + 1 <= width) {
|
|
415
|
+
line += ` ${word}`;
|
|
416
|
+
} else {
|
|
417
|
+
lines.push(line);
|
|
418
|
+
line = word;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (line) lines.push(line);
|
|
423
|
+
return lines;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function printField(label, value, options = {}) {
|
|
427
|
+
if (!value) return;
|
|
428
|
+
|
|
429
|
+
const indent = options.indent ?? "";
|
|
430
|
+
const prefixText = `${indent}${label}: `;
|
|
431
|
+
const labelStyle = options.labelStyle ?? style.gray;
|
|
432
|
+
const firstPrefix = `${indent}${labelStyle(`${label}:`)} `;
|
|
433
|
+
const nextPrefix = " ".repeat(prefixText.length);
|
|
434
|
+
const available = Math.max(32, terminalWidth() - prefixText.length);
|
|
435
|
+
const lines = wrapWords(value, available);
|
|
436
|
+
|
|
437
|
+
console.log(`${firstPrefix}${lines[0]}`);
|
|
438
|
+
for (const line of lines.slice(1)) {
|
|
439
|
+
console.log(`${nextPrefix}${line}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function shortTitle(value, max = 96) {
|
|
444
|
+
if (!value || value.length <= max) return value;
|
|
445
|
+
return `${value.slice(0, max - 3).trimEnd()}...`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function confidenceBadge(confidence) {
|
|
449
|
+
const normalized = confidence?.toLowerCase();
|
|
450
|
+
if (normalized === "high") return style.green("high");
|
|
451
|
+
if (normalized === "medium") return style.yellow("medium");
|
|
452
|
+
if (normalized === "low") return style.red("low");
|
|
453
|
+
return confidence ?? "unknown";
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function patchBadge(patchCheck) {
|
|
457
|
+
return patchCheck.ok ? style.green("ready") : style.yellow("needs work");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function choice(key, label) {
|
|
461
|
+
return `${style.cyan(`[${key}]`)} ${label}`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function printSaved(filePath) {
|
|
465
|
+
printField("Saved", displayPath(filePath), { labelStyle: style.gray });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function printStep(number, total, title) {
|
|
469
|
+
console.log(`\n${style.bold(style.cyan(`Step ${number}/${total}`))} ${style.bold(title)}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function getTargetPath(args) {
|
|
473
|
+
const optionValues = new Set();
|
|
474
|
+
for (const name of ["--model", "--evidence-budget"]) {
|
|
475
|
+
const value = getOptionValue(args, name);
|
|
476
|
+
if (value) optionValues.add(value);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return args.find((arg) => !arg.startsWith("-") && !optionValues.has(arg)) ?? ".";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function loadLocalEnv() {
|
|
483
|
+
const envPath = path.join(REVIEWER_ROOT, ".env");
|
|
484
|
+
const source = await readFile(envPath, "utf8").catch(() => "");
|
|
485
|
+
if (!source) return;
|
|
486
|
+
|
|
487
|
+
for (const line of source.split("\n")) {
|
|
488
|
+
const trimmed = line.trim();
|
|
489
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
490
|
+
|
|
491
|
+
const separatorIndex = trimmed.indexOf("=");
|
|
492
|
+
if (separatorIndex < 1) continue;
|
|
493
|
+
|
|
494
|
+
const name = trimmed.slice(0, separatorIndex).trim().replace(/^export\s+/, "");
|
|
495
|
+
const rawValue = trimmed.slice(separatorIndex + 1).trim();
|
|
496
|
+
const value = rawValue.replace(/^["']|["']$/g, "");
|
|
497
|
+
|
|
498
|
+
if (name && process.env[name] === undefined) {
|
|
499
|
+
process.env[name] = value;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function createRunId() {
|
|
505
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function slugify(value) {
|
|
509
|
+
return value
|
|
510
|
+
.toLowerCase()
|
|
511
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
512
|
+
.replace(/^-+|-+$/g, "")
|
|
513
|
+
.slice(0, 64) || "finding";
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function parseFindings(reviewText) {
|
|
517
|
+
const matches = [...reviewText.matchAll(/^Finding\s+(\d+):\s+(.+)$/gm)];
|
|
518
|
+
return matches.map((match, index) => {
|
|
519
|
+
const start = match.index ?? 0;
|
|
520
|
+
const end = matches[index + 1]?.index ?? reviewText.length;
|
|
521
|
+
return {
|
|
522
|
+
number: Number(match[1]),
|
|
523
|
+
title: match[2].trim(),
|
|
524
|
+
text: reviewText.slice(start, end).trim(),
|
|
525
|
+
};
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function extractBulletValue(text, label) {
|
|
530
|
+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
531
|
+
const match = text.match(new RegExp(`^- ${escaped}:\\s*(.+)$`, "m"));
|
|
532
|
+
return match?.[1]?.trim() ?? "";
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function extractReviewAgent(reviewText, root) {
|
|
536
|
+
const match = reviewText.match(/^Agent:\s*(.*)$/m);
|
|
537
|
+
const declared = match?.[1]?.trim();
|
|
538
|
+
if (declared) return declared;
|
|
539
|
+
return path.basename(root);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function summarizePatch(patchText) {
|
|
543
|
+
if (!patchText) return "No patch proposed";
|
|
544
|
+
const files = listPatchFiles(patchText);
|
|
545
|
+
let added = 0;
|
|
546
|
+
let removed = 0;
|
|
547
|
+
|
|
548
|
+
for (const line of patchText.split("\n")) {
|
|
549
|
+
if (line.startsWith("+") && !line.startsWith("+++")) added += 1;
|
|
550
|
+
if (line.startsWith("-") && !line.startsWith("---")) removed += 1;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const fileLabel = files.size === 1 ? "1 file" : `${files.size || "unknown"} files`;
|
|
554
|
+
return `${fileLabel}, +${added}/-${removed}`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function patchFileList(patchText) {
|
|
558
|
+
const files = [...listPatchFiles(patchText)];
|
|
559
|
+
return files.length ? files.join(", ") : "unknown";
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function listPatchFiles(patchText) {
|
|
563
|
+
const files = new Set();
|
|
564
|
+
|
|
565
|
+
for (const line of patchText.split("\n")) {
|
|
566
|
+
const fileMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
567
|
+
if (fileMatch) files.add(fileMatch[2]);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return files;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function printReviewMenu({ reviewText, findings, root, runDir, reviewPath }) {
|
|
574
|
+
const agentName = extractReviewAgent(reviewText, root);
|
|
575
|
+
const displayAgent = agentName.length > 72 ? path.basename(root) : agentName;
|
|
576
|
+
printStep(1, 5, "Review");
|
|
577
|
+
printField("Agent", displayAgent, { labelStyle: style.cyan });
|
|
578
|
+
printField("Findings", `${findings.length} ${findings.length === 1 ? "finding" : "findings"} worth your attention`, { labelStyle: style.yellow });
|
|
579
|
+
printField("Artifact", displayPath(reviewPath ?? path.join(runDir, "review.md")), { labelStyle: style.gray });
|
|
580
|
+
|
|
581
|
+
for (const finding of findings) {
|
|
582
|
+
const explanation = extractBulletValue(finding.text, "Explanation")
|
|
583
|
+
|| extractBulletValue(finding.text, "Why this can surprise you");
|
|
584
|
+
const suggestedAction = extractBulletValue(finding.text, "Suggested action")
|
|
585
|
+
|| extractBulletValue(finding.text, "Smallest check");
|
|
586
|
+
console.log(`\n${style.yellow(`#${finding.number}`)} ${style.bold(shortTitle(finding.title))}`);
|
|
587
|
+
printField("Why", explanation, { indent: " ", labelStyle: style.magenta });
|
|
588
|
+
printField("Suggested fix", suggestedAction, { indent: " ", labelStyle: style.cyan });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function promptForFindingSelection(rl, findings) {
|
|
593
|
+
const validNumbers = new Set(findings.map((finding) => String(finding.number)));
|
|
594
|
+
while (true) {
|
|
595
|
+
printStep(2, 5, "Choose Findings");
|
|
596
|
+
const answer = (await rl.question(`${choice("1", "one")} ${choice("1,2", "several")} ${choice("a", "all")} ${choice("q", "quit")}\n> `)).trim().toLowerCase();
|
|
597
|
+
if (answer === "q" || answer === "quit") return [];
|
|
598
|
+
if (answer === "a" || answer === "all") return findings;
|
|
599
|
+
const numbers = answer.split(",").map((part) => part.trim()).filter(Boolean);
|
|
600
|
+
if (numbers.length && numbers.every((number) => validNumbers.has(number))) {
|
|
601
|
+
const selected = findings.filter((finding) => numbers.includes(String(finding.number)));
|
|
602
|
+
console.log(`${style.dim("Selected:")} ${selected.map((finding) => `#${finding.number}`).join(", ")}`);
|
|
603
|
+
return selected;
|
|
604
|
+
}
|
|
605
|
+
console.log(style.yellow("Choose one or more finding numbers, all, or quit."));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function printActionSummary({ actionText, patchText, patchCheck, actionPath }) {
|
|
610
|
+
const finding = actionText.match(/^Finding:\s*(.+)$/m)?.[1]?.trim();
|
|
611
|
+
const confidence = actionText.match(/^Confidence:\s*(.+)$/m)?.[1]?.trim();
|
|
612
|
+
const proof = extractBulletValue(actionText, "Proof");
|
|
613
|
+
const proofCommand = extractBulletValue(actionText, "Proof command");
|
|
614
|
+
const fix = extractBulletValue(actionText, "Smallest fix");
|
|
615
|
+
const files = extractBulletValue(actionText, "Files");
|
|
616
|
+
|
|
617
|
+
printStep(3, 5, "Proposed Fix");
|
|
618
|
+
if (finding) console.log(style.bold(shortTitle(finding.replace(/^Finding\s+\d+:\s*/, ""))));
|
|
619
|
+
if (confidence) console.log(`${style.dim("Confidence:")} ${confidenceBadge(confidence)}`);
|
|
620
|
+
printField("Change", fix, { labelStyle: style.cyan });
|
|
621
|
+
printField("Proof", proofCommand || proof, { labelStyle: style.green });
|
|
622
|
+
printField("Files", files, { labelStyle: style.yellow });
|
|
623
|
+
|
|
624
|
+
if (!patchText) {
|
|
625
|
+
printField("Patch", "none proposed", { labelStyle: style.yellow });
|
|
626
|
+
printField("Next", `${choice("c", "comment")} ask for a different action ${choice("r", "refuse")}`, { labelStyle: style.gray });
|
|
627
|
+
if (actionPath) printField("Artifact", displayPath(actionPath), { labelStyle: style.gray });
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
printField("Patch", `${patchBadge(patchCheck)} (${summarizePatch(patchText)})`, { labelStyle: style.gray });
|
|
632
|
+
if (!patchCheck.ok) {
|
|
633
|
+
printField("Next", `${choice("c", "comment")} ask for a smaller edit ${choice("r", "refuse")}`, { labelStyle: style.gray });
|
|
634
|
+
} else {
|
|
635
|
+
printField("Next", `${choice("a", "apply")} ${choice("v", "view diff")} ${choice("c", "comment")}`, { labelStyle: style.gray });
|
|
636
|
+
}
|
|
637
|
+
if (actionPath) printField("Artifact", displayPath(actionPath), { labelStyle: style.gray });
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function extractReferencedFiles(text) {
|
|
641
|
+
const files = new Set();
|
|
642
|
+
const pattern = /`?([A-Za-z0-9_./@-]+\.(?:c|cc|cfg|cjs|cpp|cs|css|go|html|ini|java|js|json|jsx|kt|lua|md|mjs|php|prompt|properties|py|rb|rs|sh|sql|toml|ts|tsx|txt|xml|ya?ml)):\d+(?:-\d+)?`?/g;
|
|
643
|
+
let match;
|
|
644
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
645
|
+
files.add(match[1]);
|
|
646
|
+
}
|
|
647
|
+
return files;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function buildRawSourcePack(sources, referencedFiles, budget = 90_000) {
|
|
651
|
+
const prioritized = [];
|
|
652
|
+
const rest = [];
|
|
653
|
+
|
|
654
|
+
for (const item of sources) {
|
|
655
|
+
if (referencedFiles.has(item.file)) {
|
|
656
|
+
prioritized.push(item);
|
|
657
|
+
} else {
|
|
658
|
+
rest.push(item);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const selected = prioritized.length ? prioritized : rest.slice(0, 20);
|
|
663
|
+
const sections = [
|
|
664
|
+
"# Raw Source For Patch Generation",
|
|
665
|
+
"",
|
|
666
|
+
"These excerpts are raw source, not line-numbered evidence. Use them when generating unified diffs.",
|
|
667
|
+
];
|
|
668
|
+
|
|
669
|
+
let remaining = budget;
|
|
670
|
+
for (const item of selected) {
|
|
671
|
+
if (remaining < 1_500) break;
|
|
672
|
+
const language = path.extname(item.file).replace(".", "") || "text";
|
|
673
|
+
const source = redactSecrets(item.source);
|
|
674
|
+
const body = source.length > remaining ? `${source.slice(0, remaining - 200)}\n... truncated ...` : source;
|
|
675
|
+
const block = [
|
|
676
|
+
"",
|
|
677
|
+
`## ${item.file}`,
|
|
678
|
+
`\`\`\`${language}`,
|
|
679
|
+
body,
|
|
680
|
+
"```",
|
|
681
|
+
].join("\n");
|
|
682
|
+
if (block.length > remaining + 200) break;
|
|
683
|
+
sections.push(block);
|
|
684
|
+
remaining -= block.length;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return sections.join("\n");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function extractPatch(actionText) {
|
|
691
|
+
const fenced = actionText.match(/```(?:diff|patch)?\s*\n([\s\S]*?)\n```/i);
|
|
692
|
+
const candidate = (fenced ? fenced[1] : actionText).trim();
|
|
693
|
+
|
|
694
|
+
if (!candidate || /\bNO_PATCH\b/i.test(candidate)) {
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const diffIndex = candidate.indexOf("diff --git ");
|
|
699
|
+
if (diffIndex >= 0) {
|
|
700
|
+
return candidate.slice(diffIndex).trimEnd() + "\n";
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (/^---\s+/m.test(candidate) && /^\+\+\+\s+/m.test(candidate) && /^@@\s+/m.test(candidate)) {
|
|
704
|
+
return candidate.trimEnd() + "\n";
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function runCommand(command, args, options = {}) {
|
|
711
|
+
return new Promise((resolve) => {
|
|
712
|
+
const child = spawn(command, args, {
|
|
713
|
+
cwd: options.cwd,
|
|
714
|
+
env: {
|
|
715
|
+
...process.env,
|
|
716
|
+
...(options.env ?? {}),
|
|
717
|
+
},
|
|
718
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
let stdout = "";
|
|
722
|
+
let stderr = "";
|
|
723
|
+
let timedOut = false;
|
|
724
|
+
let timeout = null;
|
|
725
|
+
|
|
726
|
+
if (options.timeoutMs) {
|
|
727
|
+
timeout = setTimeout(() => {
|
|
728
|
+
timedOut = true;
|
|
729
|
+
child.kill("SIGTERM");
|
|
730
|
+
}, options.timeoutMs);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
child.stdout.on("data", (chunk) => {
|
|
734
|
+
stdout += chunk.toString();
|
|
735
|
+
});
|
|
736
|
+
child.stderr.on("data", (chunk) => {
|
|
737
|
+
stderr += chunk.toString();
|
|
738
|
+
});
|
|
739
|
+
child.on("error", (error) => {
|
|
740
|
+
if (timeout) clearTimeout(timeout);
|
|
741
|
+
resolve({ ok: false, code: 1, stdout, stderr: error.message });
|
|
742
|
+
});
|
|
743
|
+
child.on("close", (code) => {
|
|
744
|
+
if (timeout) clearTimeout(timeout);
|
|
745
|
+
resolve({ ok: code === 0 && !timedOut, code, stdout, stderr, timedOut });
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
if (options.stdinText) {
|
|
749
|
+
child.stdin.write(options.stdinText);
|
|
750
|
+
}
|
|
751
|
+
child.stdin.end();
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function checkPatch(root, patchText) {
|
|
756
|
+
const result = await runCommand("git", ["apply", "--verbose", "--check", "--recount", "--whitespace=nowarn"], {
|
|
757
|
+
cwd: root,
|
|
758
|
+
env: { GIT_CEILING_DIRECTORIES: path.dirname(root) },
|
|
759
|
+
stdinText: patchText,
|
|
760
|
+
});
|
|
761
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
762
|
+
if (/\bSkipped patch\b/i.test(output)) {
|
|
763
|
+
return { ...result, ok: false, stderr: output.trim() || "Patch was skipped by git apply." };
|
|
764
|
+
}
|
|
765
|
+
return result;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function applyPatch(root, patchText) {
|
|
769
|
+
return runCommand("git", ["apply", "--recount", "--whitespace=nowarn"], {
|
|
770
|
+
cwd: root,
|
|
771
|
+
env: { GIT_CEILING_DIRECTORIES: path.dirname(root) },
|
|
772
|
+
stdinText: patchText,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function truncateOutput(value, max = 6_000) {
|
|
777
|
+
const clean = redactSecrets(String(value ?? ""));
|
|
778
|
+
if (clean.length <= max) return clean;
|
|
779
|
+
return `${clean.slice(0, max)}\n... truncated ...`;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function isSafeRelativeFile(file) {
|
|
783
|
+
return file
|
|
784
|
+
&& !path.isAbsolute(file)
|
|
785
|
+
&& !file.split(/[\\/]+/).includes("..");
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function patchFiles(patchText) {
|
|
789
|
+
return [...listPatchFiles(patchText)].filter(isSafeRelativeFile).sort();
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function splitProofCommand(commandText) {
|
|
793
|
+
const value = String(commandText ?? "").trim();
|
|
794
|
+
if (!value || /^NO_COMMAND$/i.test(value)) return null;
|
|
795
|
+
if (/[;&|<>`$\\]/.test(value)) return { error: "Proof command contains shell syntax and was not run." };
|
|
796
|
+
const parts = value.split(/\s+/).filter(Boolean);
|
|
797
|
+
return parts.length ? { parts } : null;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function safeProofCommandFromText(commandText) {
|
|
801
|
+
const parsed = splitProofCommand(commandText);
|
|
802
|
+
if (!parsed || parsed.error) return parsed;
|
|
803
|
+
|
|
804
|
+
const [command, ...args] = parsed.parts;
|
|
805
|
+
const files = args.filter((arg) => /\.[A-Za-z0-9]+$/.test(arg));
|
|
806
|
+
if (files.some((file) => !isSafeRelativeFile(file))) {
|
|
807
|
+
return { error: "Proof command contains an unsafe file path and was not run." };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (command === "node" && args[0] === "--check" && args.length > 1 && files.every((file) => /\.(?:cjs|js|mjs)$/.test(file))) {
|
|
811
|
+
return { command, args, label: parsed.parts.join(" ") };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if (command === "python3" && args[0] === "-m" && args[1] === "py_compile" && args.length > 2 && files.every((file) => file.endsWith(".py"))) {
|
|
815
|
+
return { command, args, label: parsed.parts.join(" ") };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (command === "python3" && args[0] === "-m" && args[1] === "pytest" && args.length === 2) {
|
|
819
|
+
return { command, args, label: parsed.parts.join(" ") };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (command === "python3" && args[0] === "-m" && args[1] === "unittest" && args[2] === "discover" && args.length === 3) {
|
|
823
|
+
return { command, args, label: parsed.parts.join(" ") };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (command === "node" && args[0] === "--test" && files.every((file) => /\.(?:cjs|js|mjs)$/.test(file))) {
|
|
827
|
+
return { command, args, label: parsed.parts.join(" ") };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (command === "bun" && args[0] === "test" && files.every((file) => /\.(?:cjs|js|mjs|ts|tsx)$/.test(file))) {
|
|
831
|
+
return { command, args, label: parsed.parts.join(" ") };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (command === "npm" && args.length === 2 && args[0] === "run" && args[1] === "check") {
|
|
835
|
+
return { command, args, label: parsed.parts.join(" ") };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (command === "npm" && args.length === 1 && args[0] === "test") {
|
|
839
|
+
return { command, args, label: parsed.parts.join(" ") };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
return { error: "Proof command is not in Rippletide's local allowlist." };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function commandProofType(proofCommand) {
|
|
846
|
+
if (!proofCommand) return "manual";
|
|
847
|
+
if (proofCommand.command === "node" && proofCommand.args[0] === "--check") return "code";
|
|
848
|
+
if (proofCommand.command === "python3" && proofCommand.args[0] === "-m" && proofCommand.args[1] === "py_compile") return "code";
|
|
849
|
+
return "behavior";
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function proofType(actionText, proofCommand) {
|
|
853
|
+
const declared = extractBulletValue(actionText, "Proof type").toLowerCase();
|
|
854
|
+
if (["behavior", "code", "manual"].includes(declared)) return declared;
|
|
855
|
+
return commandProofType(proofCommand);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function packageScripts(root) {
|
|
859
|
+
const source = await readFile(path.join(root, "package.json"), "utf8").catch(() => "");
|
|
860
|
+
if (!source) return {};
|
|
861
|
+
try {
|
|
862
|
+
return JSON.parse(source).scripts ?? {};
|
|
863
|
+
} catch {
|
|
864
|
+
return {};
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async function inferredProofCommand(root, files) {
|
|
869
|
+
const scripts = await packageScripts(root);
|
|
870
|
+
if (scripts.check) return { command: "npm", args: ["run", "check"], label: "npm run check" };
|
|
871
|
+
if (scripts.test) return { command: "npm", args: ["test"], label: "npm test" };
|
|
872
|
+
|
|
873
|
+
const pythonFiles = files.filter((file) => file.endsWith(".py"));
|
|
874
|
+
if (pythonFiles.length) {
|
|
875
|
+
return { command: "python3", args: ["-m", "py_compile", ...pythonFiles], label: `python3 -m py_compile ${pythonFiles.join(" ")}` };
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const nodeFiles = files.filter((file) => /\.(?:cjs|js|mjs)$/.test(file));
|
|
879
|
+
if (nodeFiles.length) {
|
|
880
|
+
return { command: "node", args: ["--check", ...nodeFiles], label: `node --check ${nodeFiles.join(" ")}` };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const pyproject = await readFile(path.join(root, "pyproject.toml"), "utf8").catch(() => "");
|
|
884
|
+
if (/\bpytest\b/.test(pyproject)) {
|
|
885
|
+
return { command: "python3", args: ["-m", "pytest"], label: "python3 -m pytest" };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export async function runProof(root, patchText, actionText = "") {
|
|
892
|
+
const files = patchFiles(patchText);
|
|
893
|
+
const requested = extractBulletValue(actionText, "Proof command");
|
|
894
|
+
const method = extractBulletValue(actionText, "Proof");
|
|
895
|
+
const passCondition = extractBulletValue(actionText, "Pass");
|
|
896
|
+
const failCondition = extractBulletValue(actionText, "Fail");
|
|
897
|
+
const safeRequested = safeProofCommandFromText(requested);
|
|
898
|
+
if (safeRequested?.error) {
|
|
899
|
+
return {
|
|
900
|
+
command: requested,
|
|
901
|
+
failCondition,
|
|
902
|
+
files,
|
|
903
|
+
method,
|
|
904
|
+
passCondition,
|
|
905
|
+
proofType: "manual",
|
|
906
|
+
reason: safeRequested.error,
|
|
907
|
+
status: "not proven",
|
|
908
|
+
stderr: "",
|
|
909
|
+
stdout: "",
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const proofCommand = safeRequested ?? await inferredProofCommand(root, files);
|
|
914
|
+
if (!proofCommand) {
|
|
915
|
+
return {
|
|
916
|
+
command: "NO_COMMAND",
|
|
917
|
+
failCondition,
|
|
918
|
+
files,
|
|
919
|
+
method,
|
|
920
|
+
passCondition,
|
|
921
|
+
proofType: "manual",
|
|
922
|
+
reason: "No safe local proof command was available.",
|
|
923
|
+
status: "not proven",
|
|
924
|
+
stderr: "",
|
|
925
|
+
stdout: "",
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const result = await runCommand(proofCommand.command, proofCommand.args, {
|
|
930
|
+
cwd: root,
|
|
931
|
+
env: { GIT_CEILING_DIRECTORIES: path.dirname(root) },
|
|
932
|
+
timeoutMs: 120_000,
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
return {
|
|
936
|
+
code: result.code,
|
|
937
|
+
command: proofCommand.label,
|
|
938
|
+
failCondition,
|
|
939
|
+
files,
|
|
940
|
+
method,
|
|
941
|
+
passCondition,
|
|
942
|
+
proofType: proofType(actionText, proofCommand),
|
|
943
|
+
reason: result.timedOut ? "Proof command timed out." : "",
|
|
944
|
+
status: result.ok ? "passed" : "failed",
|
|
945
|
+
stderr: truncateOutput(result.stderr),
|
|
946
|
+
stdout: truncateOutput(result.stdout),
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function renderProofReport(proof) {
|
|
951
|
+
return [
|
|
952
|
+
"# Rippletide Proof",
|
|
953
|
+
"",
|
|
954
|
+
`Status: ${proof.status}`,
|
|
955
|
+
`Proof type: ${proof.proofType}`,
|
|
956
|
+
`What was checked: ${proof.method || "Safe local command completed."}`,
|
|
957
|
+
proof.passCondition ? `Pass condition: ${proof.passCondition}` : "",
|
|
958
|
+
proof.failCondition ? `Fail condition: ${proof.failCondition}` : "",
|
|
959
|
+
`Command: ${proof.command}`,
|
|
960
|
+
`Files: ${proof.files.length ? proof.files.join(", ") : "none"}`,
|
|
961
|
+
proof.reason ? `Reason: ${proof.reason}` : "",
|
|
962
|
+
proof.code === undefined ? "" : `Exit code: ${proof.code}`,
|
|
963
|
+
proof.status === "passed" && proof.proofType === "behavior" ? "Evidence: the behavior check exited 0 and satisfied the pass condition above." : "",
|
|
964
|
+
proof.status === "passed" && proof.proofType === "code" ? "Evidence: the code check exited 0. Behavior still needs a behavior test/eval." : "",
|
|
965
|
+
proof.proofType === "behavior"
|
|
966
|
+
? "Limit: this proves the local behavior check above, not external services or live production behavior."
|
|
967
|
+
: "Limit: this is not behavior proof. It only proves the local code check above.",
|
|
968
|
+
"",
|
|
969
|
+
"## stdout",
|
|
970
|
+
"```text",
|
|
971
|
+
proof.stdout || "",
|
|
972
|
+
"```",
|
|
973
|
+
"",
|
|
974
|
+
"## stderr",
|
|
975
|
+
"```text",
|
|
976
|
+
proof.stderr || "",
|
|
977
|
+
"```",
|
|
978
|
+
"",
|
|
979
|
+
].filter((line) => line !== "").join("\n");
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
export async function writeProofFile(filePath, proof) {
|
|
983
|
+
await writeActionFile(filePath, renderProofReport(proof));
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function proofOutcome(proof) {
|
|
987
|
+
if (proof.status === "passed" && proof.proofType === "behavior") return "Fix applied and behavior proof passed";
|
|
988
|
+
if (proof.status === "passed") return "Fix applied, code check passed";
|
|
989
|
+
if (proof.status === "failed") return "Fix applied, local proof failed";
|
|
990
|
+
return "Fix applied, no local proof";
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function printProofSummary(proof, proofPath, patchText) {
|
|
994
|
+
printStep(5, 5, "Proof");
|
|
995
|
+
printField("Result", proofOutcome(proof), { labelStyle: proof.status === "passed" ? style.green : style.yellow });
|
|
996
|
+
printField("Proof type", proof.proofType, { labelStyle: proof.proofType === "behavior" ? style.green : style.yellow });
|
|
997
|
+
printField("Checked", proof.method, { labelStyle: style.magenta });
|
|
998
|
+
printField("Changed", `${patchFileList(patchText)} (${summarizePatch(patchText)})`, { labelStyle: style.cyan });
|
|
999
|
+
printField("Command", proof.command, { labelStyle: style.cyan });
|
|
1000
|
+
printField("Pass condition", proof.passCondition, { labelStyle: style.green });
|
|
1001
|
+
const evidence = proof.status === "passed" && proof.proofType === "behavior"
|
|
1002
|
+
? "behavior check exited 0"
|
|
1003
|
+
: proof.status === "passed"
|
|
1004
|
+
? "code check exited 0; behavior not proven"
|
|
1005
|
+
: proof.reason || "command did not satisfy the pass condition";
|
|
1006
|
+
printField("Evidence", evidence, { labelStyle: proof.status === "passed" && proof.proofType === "behavior" ? style.green : style.yellow });
|
|
1007
|
+
printField("Artifact", displayPath(proofPath), { labelStyle: style.gray });
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
async function writeActionFile(filePath, content) {
|
|
1011
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
1012
|
+
await writeFile(filePath, content, "utf8");
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async function appendDecision(filePath, content) {
|
|
1016
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
1017
|
+
await appendFile(filePath, `${content.trimEnd()}\n`, "utf8");
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
let fakeModelResponses = null;
|
|
1021
|
+
|
|
1022
|
+
function fakeModelPath() {
|
|
1023
|
+
return process.env.RIPPLETIDE_TEST_MODEL_RESPONSES || "";
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
async function nextFakeModelResponse() {
|
|
1027
|
+
if (!fakeModelResponses) {
|
|
1028
|
+
const source = await readFile(fakeModelPath(), "utf8");
|
|
1029
|
+
fakeModelResponses = JSON.parse(source);
|
|
1030
|
+
}
|
|
1031
|
+
if (!Array.isArray(fakeModelResponses) || !fakeModelResponses.length) {
|
|
1032
|
+
throw new Error("RIPPLETIDE_TEST_MODEL_RESPONSES did not contain enough fake responses.");
|
|
1033
|
+
}
|
|
1034
|
+
return String(fakeModelResponses.shift());
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function extractResponseText(payload) {
|
|
1038
|
+
if (typeof payload.output_text === "string" && payload.output_text.trim()) {
|
|
1039
|
+
return payload.output_text.trim();
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const parts = [];
|
|
1043
|
+
for (const item of payload.output ?? []) {
|
|
1044
|
+
for (const content of item.content ?? []) {
|
|
1045
|
+
if (typeof content.text === "string") {
|
|
1046
|
+
parts.push(content.text);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
return parts.join("\n").trim();
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async function callOpenAI({ apiKey, model, instructions, input, maxOutputTokens = 10_000 }) {
|
|
1055
|
+
if (fakeModelPath()) return nextFakeModelResponse();
|
|
1056
|
+
|
|
1057
|
+
const response = await fetch("https://api.openai.com/v1/responses", {
|
|
1058
|
+
method: "POST",
|
|
1059
|
+
headers: {
|
|
1060
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
1061
|
+
"Content-Type": "application/json",
|
|
1062
|
+
},
|
|
1063
|
+
body: JSON.stringify({
|
|
1064
|
+
model,
|
|
1065
|
+
max_output_tokens: maxOutputTokens,
|
|
1066
|
+
input: [
|
|
1067
|
+
{
|
|
1068
|
+
role: "developer",
|
|
1069
|
+
content: [
|
|
1070
|
+
{
|
|
1071
|
+
type: "input_text",
|
|
1072
|
+
text: instructions,
|
|
1073
|
+
},
|
|
1074
|
+
],
|
|
1075
|
+
},
|
|
1076
|
+
{
|
|
1077
|
+
role: "user",
|
|
1078
|
+
content: [
|
|
1079
|
+
{
|
|
1080
|
+
type: "input_text",
|
|
1081
|
+
text: input,
|
|
1082
|
+
},
|
|
1083
|
+
],
|
|
1084
|
+
},
|
|
1085
|
+
],
|
|
1086
|
+
}),
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
const raw = await response.text();
|
|
1090
|
+
let payload;
|
|
1091
|
+
try {
|
|
1092
|
+
payload = JSON.parse(raw);
|
|
1093
|
+
} catch {
|
|
1094
|
+
throw new Error(`OpenAI returned a non-JSON response with status ${response.status}.`);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (!response.ok) {
|
|
1098
|
+
const message = payload.error?.message ?? raw.slice(0, 500);
|
|
1099
|
+
throw new Error(`OpenAI request failed (${response.status}): ${message}`);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const text = extractResponseText(payload);
|
|
1103
|
+
if (!text) {
|
|
1104
|
+
throw new Error("OpenAI response did not contain text output.");
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
return text;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function getApiKey() {
|
|
1111
|
+
if (fakeModelPath()) return "test-api-key";
|
|
1112
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
1113
|
+
if (!apiKey) {
|
|
1114
|
+
throw new Error("OPENAI_API_KEY is required. Rippletide is LLM-based and has no deterministic fallback.");
|
|
1115
|
+
}
|
|
1116
|
+
return apiKey;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
async function inspect(targetPath, options = {}) {
|
|
1120
|
+
const root = path.resolve(targetPath ?? ".");
|
|
1121
|
+
const model = options.model ?? process.env.RIPPLETIDE_MODEL ?? process.env.OPENAI_MODEL ?? DEFAULT_MODEL;
|
|
1122
|
+
const { evidencePack } = await withStatus(
|
|
1123
|
+
`Reading ${displayPath(root)}`,
|
|
1124
|
+
() => collectAgentContext(targetPath, options),
|
|
1125
|
+
{
|
|
1126
|
+
done: (context) => `Read ${context.files.length} files and ${context.sources.length} excerpts`,
|
|
1127
|
+
},
|
|
1128
|
+
);
|
|
1129
|
+
|
|
1130
|
+
return withStatus(
|
|
1131
|
+
"Building the internal agent dossier",
|
|
1132
|
+
() => callOpenAI({
|
|
1133
|
+
apiKey: getApiKey(),
|
|
1134
|
+
model,
|
|
1135
|
+
instructions: INSPECTOR_INSTRUCTIONS,
|
|
1136
|
+
input: evidencePack,
|
|
1137
|
+
maxOutputTokens: 10_000,
|
|
1138
|
+
}),
|
|
1139
|
+
{
|
|
1140
|
+
done: "Inspection ready",
|
|
1141
|
+
},
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
async function buildReviewArtifacts(targetPath, options = {}) {
|
|
1146
|
+
const root = path.resolve(targetPath ?? ".");
|
|
1147
|
+
const model = options.model ?? process.env.RIPPLETIDE_MODEL ?? process.env.OPENAI_MODEL ?? DEFAULT_MODEL;
|
|
1148
|
+
const context = await withStatus(
|
|
1149
|
+
`Reading ${displayPath(root)}`,
|
|
1150
|
+
() => collectAgentContext(targetPath, options),
|
|
1151
|
+
{
|
|
1152
|
+
done: (result) => `Read ${result.files.length} files and ${result.sources.length} excerpts`,
|
|
1153
|
+
},
|
|
1154
|
+
);
|
|
1155
|
+
const { evidencePack } = context;
|
|
1156
|
+
const apiKey = getApiKey();
|
|
1157
|
+
const dossier = await withStatus(
|
|
1158
|
+
"Understanding this agent",
|
|
1159
|
+
() => callOpenAI({
|
|
1160
|
+
apiKey,
|
|
1161
|
+
model,
|
|
1162
|
+
instructions: INSPECTOR_INSTRUCTIONS,
|
|
1163
|
+
input: evidencePack,
|
|
1164
|
+
maxOutputTokens: 10_000,
|
|
1165
|
+
}),
|
|
1166
|
+
{
|
|
1167
|
+
done: "Agent understood",
|
|
1168
|
+
},
|
|
1169
|
+
);
|
|
1170
|
+
|
|
1171
|
+
const reviewText = await withStatus(
|
|
1172
|
+
"Looking for useful findings",
|
|
1173
|
+
() => callOpenAI({
|
|
1174
|
+
apiKey,
|
|
1175
|
+
model,
|
|
1176
|
+
instructions: REVIEWER_INSTRUCTIONS,
|
|
1177
|
+
input: [
|
|
1178
|
+
"# Internal Agent Dossier",
|
|
1179
|
+
dossier,
|
|
1180
|
+
"",
|
|
1181
|
+
"# Evidence Pack",
|
|
1182
|
+
evidencePack,
|
|
1183
|
+
].join("\n"),
|
|
1184
|
+
maxOutputTokens: 3_500,
|
|
1185
|
+
}),
|
|
1186
|
+
{
|
|
1187
|
+
done: "Review ready",
|
|
1188
|
+
},
|
|
1189
|
+
);
|
|
1190
|
+
|
|
1191
|
+
return {
|
|
1192
|
+
...context,
|
|
1193
|
+
apiKey,
|
|
1194
|
+
dossier,
|
|
1195
|
+
model,
|
|
1196
|
+
reviewText,
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
async function review(targetPath, options = {}) {
|
|
1201
|
+
const artifacts = await buildReviewArtifacts(targetPath, options);
|
|
1202
|
+
return artifacts.reviewText;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
async function generateActionProposal({ apiKey, model, dossier, evidencePack, reviewText, sources, finding, comment }) {
|
|
1206
|
+
const referencedFiles = extractReferencedFiles(finding.text);
|
|
1207
|
+
const rawSourcePack = buildRawSourcePack(sources, referencedFiles);
|
|
1208
|
+
|
|
1209
|
+
return callOpenAI({
|
|
1210
|
+
apiKey,
|
|
1211
|
+
model,
|
|
1212
|
+
instructions: ACTION_INSTRUCTIONS,
|
|
1213
|
+
input: [
|
|
1214
|
+
"# Finding To Act On",
|
|
1215
|
+
finding.text,
|
|
1216
|
+
"",
|
|
1217
|
+
"# Builder Comment",
|
|
1218
|
+
comment?.trim() ? comment.trim() : "None",
|
|
1219
|
+
"",
|
|
1220
|
+
"# Full User-Facing Review",
|
|
1221
|
+
reviewText,
|
|
1222
|
+
"",
|
|
1223
|
+
"# Internal Agent Dossier",
|
|
1224
|
+
dossier,
|
|
1225
|
+
"",
|
|
1226
|
+
"# Evidence Pack",
|
|
1227
|
+
evidencePack,
|
|
1228
|
+
"",
|
|
1229
|
+
rawSourcePack,
|
|
1230
|
+
].join("\n"),
|
|
1231
|
+
maxOutputTokens: 7_000,
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
async function promptForDecision(rl, { hasPatch, patchApplies, patchText }) {
|
|
1236
|
+
const choices = hasPatch && patchApplies
|
|
1237
|
+
? `${choice("a", "apply")} ${choice("v", "view diff")} ${choice("c", "comment")} ${choice("r", "refuse")} ${choice("s", "skip")} ${choice("q", "quit")}`
|
|
1238
|
+
: hasPatch
|
|
1239
|
+
? `${choice("v", "view diff")} ${choice("c", "comment")} ${choice("r", "refuse")} ${choice("s", "skip")} ${choice("q", "quit")}`
|
|
1240
|
+
: `${choice("c", "comment")} ${choice("r", "refuse")} ${choice("s", "skip")} ${choice("q", "quit")}`;
|
|
1241
|
+
|
|
1242
|
+
while (true) {
|
|
1243
|
+
const answer = (await rl.question(`\n${style.bold("Apply this fix?")} ${choices}\n> `)).trim().toLowerCase();
|
|
1244
|
+
if (["v", "view", "diff"].includes(answer) && hasPatch) {
|
|
1245
|
+
console.log(`\n${patchText}\n`);
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
if (["a", "accept"].includes(answer) && hasPatch && patchApplies) return "accept";
|
|
1249
|
+
if (["r", "refuse"].includes(answer)) return "refuse";
|
|
1250
|
+
if (["c", "comment"].includes(answer)) return "comment";
|
|
1251
|
+
if (["s", "skip"].includes(answer)) return "skip";
|
|
1252
|
+
if (["q", "quit"].includes(answer)) return "quit";
|
|
1253
|
+
console.log(style.yellow("Please choose one of the shown options."));
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
async function promptAfterApply(rl, { hasMoreSelected }) {
|
|
1258
|
+
const continueLabel = hasMoreSelected ? "continue" : "done";
|
|
1259
|
+
while (true) {
|
|
1260
|
+
const answer = (await rl.question(`\n${style.bold("Next")} ${choice("r", "re-check findings")} ${choice("c", continueLabel)} ${choice("q", "quit")}\n> `)).trim().toLowerCase();
|
|
1261
|
+
if (answer === "r" || answer === "recheck" || answer === "re-check") return "recheck";
|
|
1262
|
+
if (answer === "c" || answer === "continue" || answer === "d" || answer === "done") return "continue";
|
|
1263
|
+
if (answer === "q" || answer === "quit") return "quit";
|
|
1264
|
+
console.log(style.yellow("Please choose re-check, continue, or quit."));
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
async function promptAfterProofFailure(rl) {
|
|
1269
|
+
while (true) {
|
|
1270
|
+
const answer = (await rl.question(`\n${style.bold("Proof failed")} ${choice("r", "regenerate")} ${choice("c", "comment")} ${choice("s", "skip")} ${choice("q", "quit")}\n> `)).trim().toLowerCase();
|
|
1271
|
+
if (answer === "r" || answer === "regenerate") return "regenerate";
|
|
1272
|
+
if (answer === "c" || answer === "comment") return "comment";
|
|
1273
|
+
if (answer === "s" || answer === "skip") return "skip";
|
|
1274
|
+
if (answer === "q" || answer === "quit") return "quit";
|
|
1275
|
+
console.log(style.yellow("Please choose regenerate, comment, skip, or quit."));
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function proofFailureComment(proof) {
|
|
1280
|
+
return [
|
|
1281
|
+
"System proof request: the previous accepted patch was applied, but local proof failed.",
|
|
1282
|
+
`Proof command: ${proof.command}`,
|
|
1283
|
+
proof.reason ? `Reason: ${proof.reason}` : "",
|
|
1284
|
+
proof.stdout ? `stdout:\n${proof.stdout}` : "",
|
|
1285
|
+
proof.stderr ? `stderr:\n${proof.stderr}` : "",
|
|
1286
|
+
"Regenerate a smaller correction for the same finding. If a clean local proof is not possible, return NO_PATCH with the manual check.",
|
|
1287
|
+
].filter(Boolean).join("\n\n");
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
async function reviewWithFix(targetPath, options = {}) {
|
|
1291
|
+
let artifacts = await buildReviewArtifacts(targetPath, options);
|
|
1292
|
+
let findings = parseFindings(artifacts.reviewText);
|
|
1293
|
+
const runDir = path.join(artifacts.root, ".rippletide", "actions", createRunId());
|
|
1294
|
+
|
|
1295
|
+
await mkdir(runDir, { recursive: true });
|
|
1296
|
+
await writeActionFile(path.join(runDir, "review.md"), artifacts.reviewText);
|
|
1297
|
+
await writeActionFile(path.join(runDir, "dossier.md"), artifacts.dossier);
|
|
1298
|
+
|
|
1299
|
+
if (!findings.length) {
|
|
1300
|
+
console.log(artifacts.reviewText);
|
|
1301
|
+
console.log(`\nNo findings to fix. Saved review: ${path.join(runDir, "review.md")}`);
|
|
1302
|
+
return artifacts.reviewText;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
printReviewMenu({
|
|
1306
|
+
reviewText: artifacts.reviewText,
|
|
1307
|
+
findings,
|
|
1308
|
+
root: artifacts.root,
|
|
1309
|
+
runDir,
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
const rl = createInterface({
|
|
1313
|
+
input: process.stdin,
|
|
1314
|
+
output: process.stdout,
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
try {
|
|
1318
|
+
let selectedFindings = await promptForFindingSelection(rl, findings);
|
|
1319
|
+
if (!selectedFindings.length) {
|
|
1320
|
+
console.log("Stopped fix mode.");
|
|
1321
|
+
return artifacts.reviewText;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
printField("Run artifacts", displayPath(runDir), { labelStyle: style.gray });
|
|
1325
|
+
|
|
1326
|
+
for (let selectedIndex = 0; selectedIndex < selectedFindings.length; selectedIndex += 1) {
|
|
1327
|
+
const finding = selectedFindings[selectedIndex];
|
|
1328
|
+
const findingDir = path.join(
|
|
1329
|
+
runDir,
|
|
1330
|
+
`finding-${finding.number}-${slugify(finding.title)}`,
|
|
1331
|
+
);
|
|
1332
|
+
await mkdir(findingDir, { recursive: true });
|
|
1333
|
+
await writeActionFile(path.join(findingDir, "finding.md"), finding.text);
|
|
1334
|
+
|
|
1335
|
+
let comment = "";
|
|
1336
|
+
|
|
1337
|
+
while (true) {
|
|
1338
|
+
console.log(`\n${style.dim("Working on")} ${style.bold(`#${finding.number}`)} ${shortTitle(finding.title)}`);
|
|
1339
|
+
|
|
1340
|
+
let actionText = await withStatus(
|
|
1341
|
+
`Planning an edit for Finding ${finding.number}`,
|
|
1342
|
+
() => generateActionProposal({
|
|
1343
|
+
...artifacts,
|
|
1344
|
+
finding,
|
|
1345
|
+
comment,
|
|
1346
|
+
}),
|
|
1347
|
+
{
|
|
1348
|
+
done: "Action ready",
|
|
1349
|
+
},
|
|
1350
|
+
);
|
|
1351
|
+
let patchText = extractPatch(actionText);
|
|
1352
|
+
let patchCheck = patchText
|
|
1353
|
+
? await checkPatch(artifacts.root, patchText)
|
|
1354
|
+
: { ok: false, stderr: "No patch was proposed." };
|
|
1355
|
+
|
|
1356
|
+
if (patchText && !patchCheck.ok) {
|
|
1357
|
+
const invalidSuffix = `invalid-${Date.now()}`;
|
|
1358
|
+
await writeActionFile(path.join(findingDir, `${invalidSuffix}.md`), actionText);
|
|
1359
|
+
await writeActionFile(path.join(findingDir, `${invalidSuffix}.patch`), patchText);
|
|
1360
|
+
|
|
1361
|
+
const details = (patchCheck.stderr || patchCheck.stdout).trim();
|
|
1362
|
+
console.log(style.yellow("Initial edit did not apply cleanly. Repairing once..."));
|
|
1363
|
+
|
|
1364
|
+
actionText = await withStatus(
|
|
1365
|
+
"Repairing the proposed edit",
|
|
1366
|
+
() => generateActionProposal({
|
|
1367
|
+
...artifacts,
|
|
1368
|
+
finding,
|
|
1369
|
+
comment: [
|
|
1370
|
+
comment,
|
|
1371
|
+
"System repair request: the previous unified diff failed `git apply --check`.",
|
|
1372
|
+
details ? `git apply output:\n${details}` : "",
|
|
1373
|
+
"Regenerate the same action with a smaller, valid unified diff. If a clean patch is uncertain, return NO_PATCH.",
|
|
1374
|
+
].filter(Boolean).join("\n\n"),
|
|
1375
|
+
}),
|
|
1376
|
+
{
|
|
1377
|
+
done: "Repaired action ready",
|
|
1378
|
+
},
|
|
1379
|
+
);
|
|
1380
|
+
patchText = extractPatch(actionText);
|
|
1381
|
+
patchCheck = patchText
|
|
1382
|
+
? await checkPatch(artifacts.root, patchText)
|
|
1383
|
+
: { ok: false, stderr: "No patch was proposed." };
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const proposalSuffix = comment ? `regenerated-${Date.now()}` : "proposal";
|
|
1387
|
+
const proposalPath = path.join(findingDir, `${proposalSuffix}.md`);
|
|
1388
|
+
|
|
1389
|
+
await writeActionFile(proposalPath, actionText);
|
|
1390
|
+
if (patchText) {
|
|
1391
|
+
await writeActionFile(path.join(findingDir, `${proposalSuffix}.patch`), patchText);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
printActionSummary({
|
|
1395
|
+
actionPath: proposalPath,
|
|
1396
|
+
actionText,
|
|
1397
|
+
patchCheck,
|
|
1398
|
+
patchText,
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
const decision = await promptForDecision(rl, {
|
|
1402
|
+
hasPatch: Boolean(patchText),
|
|
1403
|
+
patchApplies: patchCheck.ok,
|
|
1404
|
+
patchText,
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
if (decision === "accept") {
|
|
1408
|
+
await appendDecision(
|
|
1409
|
+
path.join(findingDir, "decisions.log"),
|
|
1410
|
+
`${new Date().toISOString()} decision=accept${comment ? ` comment=${JSON.stringify(comment)}` : ""}`,
|
|
1411
|
+
);
|
|
1412
|
+
const applyResult = await applyPatch(artifacts.root, patchText);
|
|
1413
|
+
if (!applyResult.ok) {
|
|
1414
|
+
console.log(style.yellow("Apply failed. No edit was applied."));
|
|
1415
|
+
const details = (applyResult.stderr || applyResult.stdout).trim();
|
|
1416
|
+
if (details) console.log(details);
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const stillApplies = await checkPatch(artifacts.root, patchText);
|
|
1421
|
+
if (stillApplies.ok) {
|
|
1422
|
+
console.log(style.yellow("Apply command finished, but the edit still appears unapplied. No reliable file change was confirmed."));
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
printStep(4, 5, "Apply");
|
|
1427
|
+
printField("Patch", "applied", { labelStyle: style.green });
|
|
1428
|
+
printField("Changed", `${patchFileList(patchText)} (${summarizePatch(patchText)})`, { labelStyle: style.cyan });
|
|
1429
|
+
|
|
1430
|
+
const proof = await withStatus(
|
|
1431
|
+
`Running local proof for Finding ${finding.number}`,
|
|
1432
|
+
() => runProof(artifacts.root, patchText, actionText),
|
|
1433
|
+
{
|
|
1434
|
+
done: (result) => `Proof ${result.status}`,
|
|
1435
|
+
},
|
|
1436
|
+
);
|
|
1437
|
+
const proofPath = path.join(findingDir, "proof.md");
|
|
1438
|
+
await writeProofFile(proofPath, proof);
|
|
1439
|
+
printProofSummary(proof, proofPath, patchText);
|
|
1440
|
+
|
|
1441
|
+
if (proof.status === "failed") {
|
|
1442
|
+
const proofDecision = await promptAfterProofFailure(rl);
|
|
1443
|
+
if (proofDecision === "regenerate") {
|
|
1444
|
+
comment = [comment, proofFailureComment(proof)].filter(Boolean).join("\n\n");
|
|
1445
|
+
continue;
|
|
1446
|
+
}
|
|
1447
|
+
if (proofDecision === "comment") {
|
|
1448
|
+
const builderComment = await rl.question("Tell Rippletide what to change about the proposed fix:\n> ");
|
|
1449
|
+
comment = [comment, proofFailureComment(proof), builderComment].filter(Boolean).join("\n\n");
|
|
1450
|
+
await appendDecision(path.join(findingDir, "comments.log"), `${new Date().toISOString()} ${builderComment}`);
|
|
1451
|
+
continue;
|
|
1452
|
+
}
|
|
1453
|
+
if (proofDecision === "quit") {
|
|
1454
|
+
console.log(style.dim("Stopped fix mode."));
|
|
1455
|
+
return artifacts.reviewText;
|
|
1456
|
+
}
|
|
1457
|
+
break;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const nextStep = await promptAfterApply(rl, {
|
|
1461
|
+
hasMoreSelected: selectedIndex < selectedFindings.length - 1,
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
if (nextStep === "recheck") {
|
|
1465
|
+
artifacts = await buildReviewArtifacts(artifacts.root, options);
|
|
1466
|
+
findings = parseFindings(artifacts.reviewText);
|
|
1467
|
+
|
|
1468
|
+
const refreshSuffix = `after-finding-${finding.number}-${Date.now()}`;
|
|
1469
|
+
const refreshedReviewPath = path.join(runDir, `${refreshSuffix}.review.md`);
|
|
1470
|
+
const refreshedDossierPath = path.join(runDir, `${refreshSuffix}.dossier.md`);
|
|
1471
|
+
await writeActionFile(refreshedReviewPath, artifacts.reviewText);
|
|
1472
|
+
await writeActionFile(refreshedDossierPath, artifacts.dossier);
|
|
1473
|
+
|
|
1474
|
+
if (!findings.length) {
|
|
1475
|
+
console.log(`\n${style.green("No findings remain after the edit.")}`);
|
|
1476
|
+
console.log(`${style.dim("Saved refreshed review:")} ${displayPath(refreshedReviewPath)}`);
|
|
1477
|
+
return artifacts.reviewText;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
console.log(`\n${style.green("Findings updated after your edit.")}`);
|
|
1481
|
+
printReviewMenu({
|
|
1482
|
+
reviewText: artifacts.reviewText,
|
|
1483
|
+
findings,
|
|
1484
|
+
root: artifacts.root,
|
|
1485
|
+
runDir,
|
|
1486
|
+
reviewPath: refreshedReviewPath,
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
selectedFindings = await promptForFindingSelection(rl, findings);
|
|
1490
|
+
if (!selectedFindings.length) {
|
|
1491
|
+
console.log(style.dim("Stopped fix mode."));
|
|
1492
|
+
return artifacts.reviewText;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
selectedIndex = -1;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
if (nextStep === "quit") {
|
|
1499
|
+
console.log(style.dim("Stopped fix mode."));
|
|
1500
|
+
return artifacts.reviewText;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
break;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
if (decision === "comment") {
|
|
1507
|
+
await appendDecision(
|
|
1508
|
+
path.join(findingDir, "decisions.log"),
|
|
1509
|
+
`${new Date().toISOString()} decision=${decision}${comment ? ` comment=${JSON.stringify(comment)}` : ""}`,
|
|
1510
|
+
);
|
|
1511
|
+
comment = await rl.question("Tell Rippletide what to change about the proposed fix:\n> ");
|
|
1512
|
+
await appendDecision(path.join(findingDir, "comments.log"), `${new Date().toISOString()} ${comment}`);
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
if (decision === "quit") {
|
|
1517
|
+
await appendDecision(
|
|
1518
|
+
path.join(findingDir, "decisions.log"),
|
|
1519
|
+
`${new Date().toISOString()} decision=${decision}${comment ? ` comment=${JSON.stringify(comment)}` : ""}`,
|
|
1520
|
+
);
|
|
1521
|
+
console.log(style.dim("Stopped fix mode."));
|
|
1522
|
+
return artifacts.reviewText;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
await appendDecision(
|
|
1526
|
+
path.join(findingDir, "decisions.log"),
|
|
1527
|
+
`${new Date().toISOString()} decision=${decision}${comment ? ` comment=${JSON.stringify(comment)}` : ""}`,
|
|
1528
|
+
);
|
|
1529
|
+
console.log(style.dim(decision === "refuse" ? "Refused finding." : "Skipped finding."));
|
|
1530
|
+
break;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
} finally {
|
|
1534
|
+
rl.close();
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
console.log(`\n${style.green("Fix mode complete.")} ${style.dim(`Saved in ${displayPath(runDir)}`)}`);
|
|
1538
|
+
return artifacts.reviewText;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
export async function main(argv = process.argv) {
|
|
1542
|
+
const [, , command, ...args] = argv;
|
|
1543
|
+
await loadLocalEnv();
|
|
1544
|
+
|
|
1545
|
+
if (!command || command === "--help" || command === "-h") {
|
|
1546
|
+
console.log(usage());
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
|
|
1551
|
+
console.log(usage());
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
if (command !== "inspect" && command !== "review") {
|
|
1556
|
+
console.error(`Unknown command: ${command}\n`);
|
|
1557
|
+
console.error(usage());
|
|
1558
|
+
process.exit(1);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
const model = getOptionValue(args, "--model") ?? process.env.RIPPLETIDE_MODEL ?? process.env.OPENAI_MODEL ?? DEFAULT_MODEL;
|
|
1562
|
+
const evidenceBudgetValue = getOptionValue(args, "--evidence-budget");
|
|
1563
|
+
const evidenceBudget = evidenceBudgetValue ? Number(evidenceBudgetValue) : DEFAULT_EVIDENCE_BUDGET;
|
|
1564
|
+
|
|
1565
|
+
if (!Number.isFinite(evidenceBudget) || evidenceBudget < 10_000) {
|
|
1566
|
+
throw new Error("--evidence-budget must be a number greater than or equal to 10000.");
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
if (command === "inspect") {
|
|
1570
|
+
console.log(await inspect(getTargetPath(args), { model, evidenceBudget }));
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
if (hasFlag(args, "--fix")) {
|
|
1575
|
+
await reviewWithFix(getTargetPath(args), { model, evidenceBudget });
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
console.log(await review(getTargetPath(args), { model, evidenceBudget }));
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
1583
|
+
main().catch((error) => {
|
|
1584
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1585
|
+
process.exit(1);
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
export {
|
|
1590
|
+
applyPatch,
|
|
1591
|
+
checkPatch,
|
|
1592
|
+
extractPatch,
|
|
1593
|
+
parseFindings,
|
|
1594
|
+
safeProofCommandFromText,
|
|
1595
|
+
};
|