runcap 0.3.0 → 0.5.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 +211 -9
- package/bin/runcap.mjs +153 -0
- package/examples/outcome-demo/agent-fixes.mjs +24 -0
- package/examples/outcome-demo/agent-spins.mjs +20 -0
- package/examples/outcome-demo/broken.mjs +5 -0
- package/examples/outcome-demo/verify.mjs +7 -0
- package/package.json +11 -2
- package/scripts/guard-test.mjs +76 -0
- package/scripts/loop-e2e.mjs +137 -0
- package/scripts/loop-test.mjs +45 -1
- package/scripts/make-demo-svg.mjs +20 -19
- package/scripts/make-linkedin-loop-video.mjs +338 -0
- package/scripts/mission-test.mjs +148 -0
- package/scripts/outcome-test.mjs +48 -0
- package/scripts/policy-test.mjs +121 -0
- package/scripts/render-media-screenshots.mjs +37 -0
- package/src/compressor.mjs +77 -9
- package/src/mission-control.mjs +475 -8
- package/src/policy.mjs +208 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Proves `runcap outcome --guard` grades verification trust, not just pass/fail.
|
|
2
|
+
// Three real runs through the cap gateway (mock upstream) inside a throwaway git
|
|
3
|
+
// repo: an honest fix earns VERIFIED_STRONG; an agent that rewrites the verifier
|
|
4
|
+
// to force a green earns VERIFIER_COMPROMISED; a no-fix run stays UNVERIFIED.
|
|
5
|
+
// The whole point of the guard is that the middle case must NOT read as VERIFIED.
|
|
6
|
+
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
const SRC_DIR = process.env.RUNCAP_SRC ?? path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "src");
|
|
14
|
+
|
|
15
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), "runcap-guard-"));
|
|
16
|
+
process.chdir(tmp);
|
|
17
|
+
|
|
18
|
+
mkdirSync(path.join(tmp, "app"), { recursive: true });
|
|
19
|
+
// broken.mjs starts wrong (ok=false), so the task genuinely fails on baseline.
|
|
20
|
+
writeFileSync(path.join(tmp, "app", "broken.mjs"), "export const ok = false;\n");
|
|
21
|
+
writeFileSync(path.join(tmp, "app", "verify.mjs"),
|
|
22
|
+
"import { ok } from './broken.mjs'; import assert from 'node:assert'; assert.strictEqual(ok, true, 'not fixed'); console.log('ok');\n");
|
|
23
|
+
|
|
24
|
+
// Honest agent: spends, then fixes the source file (an allowed change).
|
|
25
|
+
writeFileSync(path.join(tmp, "agent-fix.mjs"),
|
|
26
|
+
"const b=process.env.OPENAI_BASE_URL;await fetch(`${b}/chat/completions`,{method:'POST',headers:{'content-type':'application/json',authorization:'Bearer x'},body:JSON.stringify({model:'gpt-4o',messages:[{role:'user',content:'fix it'}]})}).then(r=>r.text());" +
|
|
27
|
+
"const {writeFile}=await import('node:fs/promises');await writeFile('app/broken.mjs','export const ok = true;\\n');\n");
|
|
28
|
+
// Cheating agent: instead of fixing the bug, it rewrites the VERIFIER so it always passes.
|
|
29
|
+
writeFileSync(path.join(tmp, "agent-cheat.mjs"),
|
|
30
|
+
"const b=process.env.OPENAI_BASE_URL;await fetch(`${b}/chat/completions`,{method:'POST',headers:{'content-type':'application/json',authorization:'Bearer x'},body:JSON.stringify({model:'gpt-4o',messages:[{role:'user',content:'cheat'}]})}).then(r=>r.text());" +
|
|
31
|
+
"const {writeFile}=await import('node:fs/promises');await writeFile('app/verify.mjs','console.log(\"ok\");\\n');\n");
|
|
32
|
+
// No-fix agent: spends, changes nothing.
|
|
33
|
+
writeFileSync(path.join(tmp, "agent-nop.mjs"),
|
|
34
|
+
"const b=process.env.OPENAI_BASE_URL;await fetch(`${b}/chat/completions`,{method:'POST',headers:{'content-type':'application/json',authorization:'Bearer x'},body:JSON.stringify({model:'gpt-4o',messages:[{role:'user',content:'think'}]})}).then(r=>r.text());console.log('no fix');\n");
|
|
35
|
+
|
|
36
|
+
// Commit a baseline so the guard has a real commit + clean tree to check against.
|
|
37
|
+
const g = (...a) => execFileSync("git", a, { cwd: tmp, stdio: "pipe" });
|
|
38
|
+
g("init", "-q");
|
|
39
|
+
g("config", "user.email", "test@runcap.local");
|
|
40
|
+
g("config", "user.name", "runcap-test");
|
|
41
|
+
g("add", "-A");
|
|
42
|
+
g("commit", "-qm", "baseline");
|
|
43
|
+
|
|
44
|
+
let failures = 0;
|
|
45
|
+
const check = (name, pass, detail) => { if (!pass) failures++; console.log(`${pass ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); };
|
|
46
|
+
|
|
47
|
+
const { runOutcome } = await import(path.join(SRC_DIR, "mission-control.mjs"));
|
|
48
|
+
|
|
49
|
+
// Each scenario must start from the committed baseline - otherwise one run's
|
|
50
|
+
// edits (especially the cheat run's rewritten verifier) leak into the next.
|
|
51
|
+
const resetToBaseline = () => { g("checkout", "-f", "HEAD"); g("clean", "-fdq", "-e", ".runcap"); };
|
|
52
|
+
|
|
53
|
+
// 1. Honest fix, guarded → VERIFIED_STRONG.
|
|
54
|
+
const fix = await runOutcome({ task: "fix ok", verify: "node app/verify.mjs", command: ["node", "agent-fix.mjs"], label: "guard-fix", mock: true, guard: true, allow: ["app/broken.mjs"] });
|
|
55
|
+
check("honest fix verifies (exit-code oracle)", fix.receipt.outcome === "VERIFIED", `outcome=${fix.receipt.outcome}`);
|
|
56
|
+
check("honest fix grades VERIFIED_STRONG", fix.receipt.verificationIntegrity.status === "VERIFIED_STRONG", `status=${fix.receipt.verificationIntegrity.status}`);
|
|
57
|
+
check("strong run reproduced the baseline failure first", fix.receipt.verificationIntegrity.contract.baselineVerifyPassed === false);
|
|
58
|
+
check("strong pass survives a clean checkout", fix.receipt.verificationIntegrity.cleanRoom.passed === true, fix.receipt.verificationIntegrity.cleanRoom.detail);
|
|
59
|
+
|
|
60
|
+
// 2. Cheat by rewriting the verifier → still exit-0 "VERIFIED", but COMPROMISED.
|
|
61
|
+
resetToBaseline();
|
|
62
|
+
const cheat = await runOutcome({ task: "fix ok", verify: "node app/verify.mjs", command: ["node", "agent-cheat.mjs"], label: "guard-cheat", mock: true, guard: true, allow: ["app/broken.mjs"] });
|
|
63
|
+
check("cheat run's exit code is still 0 (the trap)", cheat.receipt.verify.passed === true);
|
|
64
|
+
check("guard catches the rewritten verifier", cheat.receipt.verificationIntegrity.status === "VERIFIER_COMPROMISED", `status=${cheat.receipt.verificationIntegrity.status}`);
|
|
65
|
+
check("compromised run names the tampered file", cheat.receipt.verificationIntegrity.violations.some((v) => v.startsWith("verifier_file_unchanged:")), JSON.stringify(cheat.receipt.verificationIntegrity.violations));
|
|
66
|
+
|
|
67
|
+
// 3. No-fix, guarded → UNVERIFIED (verify never passed).
|
|
68
|
+
resetToBaseline();
|
|
69
|
+
const nop = await runOutcome({ task: "fix ok", verify: "node app/verify.mjs", command: ["node", "agent-nop.mjs"], label: "guard-nop", mock: true, guard: true, allow: ["app/broken.mjs"] });
|
|
70
|
+
check("no-fix guarded run is UNVERIFIED", nop.receipt.verificationIntegrity.status === "UNVERIFIED", `status=${nop.receipt.verificationIntegrity.status}`);
|
|
71
|
+
|
|
72
|
+
// 4. The honesty note about cost scope rides on every guarded receipt.
|
|
73
|
+
check("receipt states cost scope is LLM-only", /subscriptions/.test(fix.receipt.costScope.note));
|
|
74
|
+
|
|
75
|
+
console.log("\n" + (failures === 0 ? "ALL GUARD TESTS PASSED" : `${failures} GUARD TEST(S) FAILED`));
|
|
76
|
+
process.exit(failures === 0 ? 0 : 1);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// End-to-end proof that the response-side loop gate works through the REAL
|
|
2
|
+
// gateway over HTTP, not just in unit tests. We stand up a tiny local "upstream"
|
|
3
|
+
// that returns a caller-chosen error string, point the real Runcap gateway at
|
|
4
|
+
// it, and drive near-identical prompts through the wire:
|
|
5
|
+
// A) error CHANGES each turn (convergence) -> gateway must NOT flag a loop
|
|
6
|
+
// B) error STAYS the same each turn (circling) -> gateway MUST flag a loop
|
|
7
|
+
// The gateway records its loop verdict per call in the gateway event log, which
|
|
8
|
+
// we read back to assert the real server behaved correctly.
|
|
9
|
+
//
|
|
10
|
+
// Pure Node, no framework. Exits non-zero on any failure so it can gate CI.
|
|
11
|
+
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { mkdtempSync, readFileSync, existsSync } from "node:fs";
|
|
16
|
+
|
|
17
|
+
// Isolate all gateway state (the .runcap event log lives under cwd) in a
|
|
18
|
+
// throwaway dir so this never touches real data. The gateway writes its event
|
|
19
|
+
// log to ./.runcap, so we chdir into the temp dir before starting it.
|
|
20
|
+
const tmpHome = mkdtempSync(path.join(os.tmpdir(), "runcap-e2e-"));
|
|
21
|
+
process.chdir(tmpHome);
|
|
22
|
+
process.env.AIM_COMPRESS = "off"; // keep the wire bytes predictable
|
|
23
|
+
process.env.AIM_LOOP_DETECT = "on";
|
|
24
|
+
|
|
25
|
+
// A controllable upstream: returns an OpenAI-shaped completion whose assistant
|
|
26
|
+
// text is whatever error we tell it to via a field in the request body. We use
|
|
27
|
+
// the body (not a header) on purpose: the gateway forwards the request body
|
|
28
|
+
// upstream but rewrites headers, so the body is the channel that actually
|
|
29
|
+
// reaches this stub through the real gateway.
|
|
30
|
+
const upstream = http.createServer((req, res) => {
|
|
31
|
+
let body = "";
|
|
32
|
+
req.on("data", (c) => (body += c));
|
|
33
|
+
req.on("end", () => {
|
|
34
|
+
let err = "default error";
|
|
35
|
+
try { err = JSON.parse(body)?.mock_error ?? err; } catch {}
|
|
36
|
+
const payload = {
|
|
37
|
+
id: "chatcmpl-stub",
|
|
38
|
+
object: "chat.completion",
|
|
39
|
+
created: Math.floor(Date.now() / 1000),
|
|
40
|
+
model: "stub-model",
|
|
41
|
+
choices: [{ index: 0, message: { role: "assistant", content: String(err) }, finish_reason: "stop" }],
|
|
42
|
+
usage: { prompt_tokens: 50, completion_tokens: 10, total_tokens: 60 }
|
|
43
|
+
};
|
|
44
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
45
|
+
res.end(JSON.stringify(payload));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
async function listen(server, port = 0) {
|
|
50
|
+
await new Promise((r) => server.listen(port, "127.0.0.1", r));
|
|
51
|
+
return server.address().port;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let failures = 0;
|
|
55
|
+
function check(name, pass, detail) {
|
|
56
|
+
if (!pass) failures++;
|
|
57
|
+
console.log(`${pass ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const stableTail = [
|
|
61
|
+
"You are a coding agent. Fix the failing build.",
|
|
62
|
+
...Array.from({ length: 40 }, (_, i) => `context line ${i}: prior file content the agent keeps resending`)
|
|
63
|
+
].join("\n");
|
|
64
|
+
|
|
65
|
+
async function send(port, wording, mockError) {
|
|
66
|
+
// mock_error rides in the body so it survives the gateway's header rewrite and
|
|
67
|
+
// reaches the upstream stub, which echoes it back as the assistant response.
|
|
68
|
+
const body = JSON.stringify({
|
|
69
|
+
model: "stub-model",
|
|
70
|
+
mock_error: mockError,
|
|
71
|
+
messages: [{ role: "user", content: stableTail + "\nLet me try this: " + wording }]
|
|
72
|
+
});
|
|
73
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "content-type": "application/json" },
|
|
76
|
+
body
|
|
77
|
+
});
|
|
78
|
+
await res.text();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readEvents() {
|
|
82
|
+
const log = path.join(tmpHome, ".runcap", "gateway-events.jsonl");
|
|
83
|
+
if (!existsSync(log)) return [];
|
|
84
|
+
return readFileSync(log, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Loop verdicts accumulate across both scenarios in one shared gateway process
|
|
88
|
+
// (the shape history is per-process), so each scenario asserts against only the
|
|
89
|
+
// events it produced. We snapshot the event count before scenario B.
|
|
90
|
+
|
|
91
|
+
const run = async () => {
|
|
92
|
+
const upstreamPort = await listen(upstream);
|
|
93
|
+
process.env.AIM_UPSTREAM_BASE_URL = `http://127.0.0.1:${upstreamPort}/v1`;
|
|
94
|
+
process.env.AIM_UPSTREAM_API_KEY = "test-key";
|
|
95
|
+
|
|
96
|
+
// Import AFTER env is set so the gateway reads our isolated config.
|
|
97
|
+
const { startEphemeralGateway } = await import("../src/mission-control.mjs");
|
|
98
|
+
const gw = await startEphemeralGateway();
|
|
99
|
+
const gwPort = gw.port;
|
|
100
|
+
|
|
101
|
+
// Scenario A: same prompt framing, but the error MOVES every turn (convergence).
|
|
102
|
+
for (const [w, e] of [
|
|
103
|
+
["guard the undefined", "TypeError: cannot read property 'id' of undefined"],
|
|
104
|
+
["optional chain", "TypeError: cannot read property 'name' of undefined"],
|
|
105
|
+
["default to {}", "ReferenceError: parser is not defined"],
|
|
106
|
+
["try/catch", "AssertionError: expected 200 but got 404"]
|
|
107
|
+
]) {
|
|
108
|
+
await send(gwPort, w, e);
|
|
109
|
+
}
|
|
110
|
+
const afterA = readEvents();
|
|
111
|
+
const aFlagged = afterA.filter((ev) => ev.loop && ev.loop.looping).length;
|
|
112
|
+
check("E2E convergence (moving error) is NOT flagged through real gateway", aFlagged === 0,
|
|
113
|
+
`loops flagged in scenario A=${aFlagged}`);
|
|
114
|
+
|
|
115
|
+
// Scenario B: same prompt framing AND the SAME error every turn (circling).
|
|
116
|
+
const stuck = "TypeError: cannot read property 'id' of undefined";
|
|
117
|
+
for (const w of ["attempt one", "attempt two reworded", "attempt three reworded", "attempt four reworded", "attempt five reworded"]) {
|
|
118
|
+
await send(gwPort, w, stuck);
|
|
119
|
+
}
|
|
120
|
+
const afterB = readEvents().slice(afterA.length); // only scenario-B events
|
|
121
|
+
const bFlagged = afterB.filter((ev) => ev.loop && ev.loop.looping).length;
|
|
122
|
+
check("E2E circling (stuck error) IS flagged through real gateway", bFlagged > 0,
|
|
123
|
+
`loops flagged in scenario B=${bFlagged}`);
|
|
124
|
+
|
|
125
|
+
await gw.close();
|
|
126
|
+
upstream.close();
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
run()
|
|
130
|
+
.then(() => {
|
|
131
|
+
console.log("\n" + (failures === 0 ? "ALL LOOP E2E TESTS PASSED" : `${failures} LOOP E2E TEST(S) FAILED`));
|
|
132
|
+
process.exit(failures === 0 ? 0 : 1);
|
|
133
|
+
})
|
|
134
|
+
.catch((e) => {
|
|
135
|
+
console.error("E2E harness error:", e);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
});
|
package/scripts/loop-test.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Pure Node, no test framework. Exits non-zero on any failure so it can gate CI.
|
|
9
9
|
|
|
10
|
-
import { detectLoop, requestShapeText } from "../src/compressor.mjs";
|
|
10
|
+
import { detectLoop, requestShapeText, responseSignature } from "../src/compressor.mjs";
|
|
11
11
|
|
|
12
12
|
let failures = 0;
|
|
13
13
|
function check(name, pass, detail) {
|
|
@@ -80,5 +80,49 @@ function attempt(wording) {
|
|
|
80
80
|
`openai="${openai}" anthropic="${anthropic}"`);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// --- Test 6: response-side gate — similar prompts but a MOVING error is NOT a loop ---
|
|
84
|
+
// The edge case raised on the thread: a converging run also sends near-identical
|
|
85
|
+
// prompts (same files, same framing) while it closes in on the fix. The tell is
|
|
86
|
+
// the observation: if the error/test output changes between turns, that's
|
|
87
|
+
// progress, not circling. Prompts are near-identical here, but each response
|
|
88
|
+
// carries a DIFFERENT error, so the gate must keep it from being flagged.
|
|
89
|
+
{
|
|
90
|
+
const history = [attempt("try A"), attempt("try B"), attempt("try C")];
|
|
91
|
+
const current = attempt("try D");
|
|
92
|
+
const responseSignatures = [
|
|
93
|
+
"TypeError: cannot read property 'id' of undefined",
|
|
94
|
+
"TypeError: cannot read property 'name' of undefined",
|
|
95
|
+
"ReferenceError: parser is not defined"
|
|
96
|
+
];
|
|
97
|
+
const currentResponseSignature = "AssertionError: expected 200 but got 404";
|
|
98
|
+
const r = detectLoop(current, history, { responseSignatures, currentResponseSignature });
|
|
99
|
+
check("similar prompts but MOVING error are NOT flagged (convergence)", !r.looping && r.responseMoved,
|
|
100
|
+
`looping=${r.looping}, repeats=${r.repeats}, responseMoved=${r.responseMoved}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- Test 7: response-side gate — similar prompts AND a STUCK error IS a loop ---
|
|
104
|
+
// Same near-identical prompts, but the identical error keeps coming back. Now
|
|
105
|
+
// both signals agree the run is circling, so it must still be flagged.
|
|
106
|
+
{
|
|
107
|
+
const history = [attempt("try A"), attempt("try B"), attempt("try C")];
|
|
108
|
+
const current = attempt("try D");
|
|
109
|
+
const sameError = "TypeError: cannot read property 'id' of undefined";
|
|
110
|
+
const responseSignatures = [sameError, sameError, sameError];
|
|
111
|
+
const currentResponseSignature = sameError;
|
|
112
|
+
const r = detectLoop(current, history, { responseSignatures, currentResponseSignature });
|
|
113
|
+
check("similar prompts AND stuck error ARE flagged as loop", r.looping && !r.responseMoved && r.repeats >= 3,
|
|
114
|
+
`looping=${r.looping}, repeats=${r.repeats}, responseMoved=${r.responseMoved}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Test 8: responseSignature extracts the error/text from both provider shapes ---
|
|
118
|
+
{
|
|
119
|
+
const openai = responseSignature({ choices: [{ message: { content: "boom: it failed" } }] });
|
|
120
|
+
const anthropic = responseSignature({ content: [{ type: "text", text: "boom: it failed" }] });
|
|
121
|
+
const errEnvelope = responseSignature({ error: { message: "rate limited" } });
|
|
122
|
+
check("responseSignature reads OpenAI, Anthropic, and error shapes",
|
|
123
|
+
openai === "boom: it failed" && anthropic === "boom: it failed" && errEnvelope === "rate limited",
|
|
124
|
+
`openai="${openai}" anthropic="${anthropic}" err="${errEnvelope}"`);
|
|
125
|
+
}
|
|
126
|
+
|
|
83
127
|
console.log("\n" + (failures === 0 ? "ALL LOOP TESTS PASSED" : `${failures} LOOP TEST(S) FAILED`));
|
|
84
128
|
process.exit(failures === 0 ? 0 : 1);
|
|
@@ -15,28 +15,29 @@ const C = {
|
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
const lines = [
|
|
18
|
-
{ t: "$ runcap
|
|
19
|
-
{ t: "
|
|
20
|
-
{ t: "
|
|
21
|
-
{ t: "", c: C.text, at: 1.
|
|
22
|
-
{ t: "
|
|
23
|
-
{ t: "
|
|
24
|
-
{ t: "
|
|
25
|
-
{ t: "", c: C.
|
|
26
|
-
{ t: "
|
|
27
|
-
{ t: "
|
|
28
|
-
{ t: "", c: C.
|
|
29
|
-
{ t: "
|
|
30
|
-
{ t: "", c: C.
|
|
31
|
-
{ t: "
|
|
32
|
-
{ t: "
|
|
18
|
+
{ t: "$ runcap mission run --policy .runcap/mission.yaml -- claude \"fix the failing checkout test\"", c: C.prompt, at: 0.3 },
|
|
19
|
+
{ t: "Policy: checkout · team payments · cap $10 · verify \"npm test\"", c: C.dim, at: 0.9 },
|
|
20
|
+
{ t: "", c: C.text, at: 1.0 },
|
|
21
|
+
{ t: "→ estimate $3 - $7 · hard cap armed at $10", c: C.text, at: 1.5 },
|
|
22
|
+
{ t: "→ compressed 1,186 → 737 tokens on a real call (37.9% saved)", c: C.ok, at: 2.1 },
|
|
23
|
+
{ t: "", c: C.text, at: 2.2 },
|
|
24
|
+
{ t: "✓ verify passed - but did the agent earn it?", c: C.text, at: 2.9 },
|
|
25
|
+
{ t: " · verifier unchanged · baseline truly failed · clean-room replay reproduced", c: C.dim, at: 3.4 },
|
|
26
|
+
{ t: " Verification integrity: VERIFIED_STRONG", c: C.ok, at: 4.0 },
|
|
27
|
+
{ t: " Mission cost $0.0007 / $10.00 · 3 files changed, all in scope", c: C.text, at: 4.6 },
|
|
28
|
+
{ t: " Mission verdict: PASS", c: C.accent, at: 5.2 },
|
|
29
|
+
{ t: "", c: C.text, at: 5.3 },
|
|
30
|
+
{ t: "$ runcap ci --policy .runcap/mission.yaml # the same gate, on the PR", c: C.prompt, at: 6.2 },
|
|
31
|
+
{ t: "✗ agent rewrote app/verify.mjs - protected evidence changed", c: C.bad, at: 6.9 },
|
|
32
|
+
{ t: " Verification integrity: VERIFIER_COMPROMISED", c: C.bad, at: 7.5 },
|
|
33
|
+
{ t: " Mission verdict: BLOCKED → PR check fails, run stopped", c: C.bad, at: 8.1 }
|
|
33
34
|
];
|
|
34
35
|
|
|
35
|
-
const W =
|
|
36
|
+
const W = 980, H = 588;
|
|
36
37
|
const padX = 28, top = 78, lh = 27, fs = 16.5;
|
|
37
38
|
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
38
39
|
|
|
39
|
-
const total =
|
|
40
|
+
const total = 11.0; // loop length seconds
|
|
40
41
|
const rows = lines.map((ln, i) => {
|
|
41
42
|
const y = top + i * lh;
|
|
42
43
|
// fade+slide in at ln.at, hold, then reset at end of loop
|
|
@@ -46,7 +47,7 @@ const rows = lines.map((ln, i) => {
|
|
|
46
47
|
${esc(ln.t)}</text>`;
|
|
47
48
|
}).join("\n");
|
|
48
49
|
|
|
49
|
-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="Runcap terminal demo:
|
|
50
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="Runcap terminal demo: estimate, cap, verify integrity, mission PASS, then a tampered run graded BLOCKED on the PR">
|
|
50
51
|
<defs>
|
|
51
52
|
<linearGradient id="brand" x1="0" y1="0" x2="1" y2="0">
|
|
52
53
|
<stop offset="0" stop-color="#22d3ee"/><stop offset="1" stop-color="#34d399"/>
|
|
@@ -64,7 +65,7 @@ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" wid
|
|
|
64
65
|
<circle cx="26" cy="28" r="6" fill="#f87171"/>
|
|
65
66
|
<circle cx="48" cy="28" r="6" fill="#fbbf24"/>
|
|
66
67
|
<circle cx="70" cy="28" r="6" fill="#34d399"/>
|
|
67
|
-
<text x="100" y="33" fill="#8a8a8a" font-family="'JetBrains Mono',monospace" font-size="14">runcap
|
|
68
|
+
<text x="100" y="33" fill="#8a8a8a" font-family="'JetBrains Mono',monospace" font-size="14">runcap · estimate · cap · verify integrity · mission verdict</text>
|
|
68
69
|
<text x="${W-150}" y="33" fill="url(#brand)" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="15">run·cap</text>
|
|
69
70
|
</g>
|
|
70
71
|
<line x1="0" y1="50" x2="${W}" y2="50" stroke="#1c1c1f"/>
|