pi-cursor-sdk 0.1.28 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +39 -36
  3. package/docs/crabbox-platform-testing-lessons.md +508 -0
  4. package/docs/cursor-dogfood-checklist.md +4 -3
  5. package/docs/cursor-live-smoke-checklist.md +22 -20
  6. package/docs/cursor-model-ux-spec.md +13 -13
  7. package/docs/cursor-native-tool-replay.md +11 -11
  8. package/docs/cursor-native-tool-visual-audit.md +9 -7
  9. package/docs/cursor-testing-lessons.md +20 -15
  10. package/docs/cursor-tool-surfaces.md +5 -5
  11. package/docs/platform-smoke.md +994 -0
  12. package/package.json +32 -3
  13. package/platform-smoke.config.mjs +21 -0
  14. package/scripts/debug-provider-events.mjs +10 -3
  15. package/scripts/debug-sdk-events.mjs +10 -2
  16. package/scripts/isolated-cursor-smoke.sh +4 -4
  17. package/scripts/lib/cursor-visual-render.mjs +1 -0
  18. package/scripts/platform-smoke/artifacts.mjs +124 -0
  19. package/scripts/platform-smoke/assertions.mjs +101 -0
  20. package/scripts/platform-smoke/card-detect.mjs +96 -0
  21. package/scripts/platform-smoke/crabbox-runner.mjs +215 -0
  22. package/scripts/platform-smoke/doctor.mjs +446 -0
  23. package/scripts/platform-smoke/jsonl-text.mjs +31 -0
  24. package/scripts/platform-smoke/live-suite-runner.mjs +677 -0
  25. package/scripts/platform-smoke/platform-build-windows.ps1 +187 -0
  26. package/scripts/platform-smoke/pty-capture.mjs +131 -0
  27. package/scripts/platform-smoke/render-ansi.mjs +65 -0
  28. package/scripts/platform-smoke/scenarios.mjs +186 -0
  29. package/scripts/platform-smoke/targets.mjs +900 -0
  30. package/scripts/platform-smoke/visual-evidence.mjs +139 -0
  31. package/scripts/platform-smoke.mjs +193 -0
  32. package/scripts/probe-mcp-coldstart.mjs +8 -1
  33. package/scripts/steering-rpc-smoke.mjs +1 -1
  34. package/scripts/tmux-live-smoke.sh +3 -3
  35. package/scripts/visual-tui-smoke.mjs +1 -1
  36. package/src/context.ts +2 -4
  37. package/src/cursor-pi-tool-bridge-abort.ts +1 -0
  38. package/src/cursor-pi-tool-bridge-diagnostics.ts +12 -1
  39. package/src/cursor-pi-tool-bridge.ts +46 -1
  40. package/src/cursor-provider-turn-lifecycle-emitter.ts +65 -8
  41. package/src/cursor-provider-turn-tool-ledger.ts +2 -3
  42. package/src/cursor-run-final-text.ts +11 -1
  43. package/src/cursor-skill-tool.ts +273 -0
  44. package/src/cursor-state.ts +38 -19
  45. package/src/cursor-tool-lifecycle.ts +1 -1
  46. package/src/cursor-tool-manifest.ts +1 -1
  47. package/src/cursor-transcript-utils.ts +7 -3
  48. package/src/index.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cursor-sdk",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "pi provider extension backed by @cursor/sdk local agents",
5
5
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
6
6
  "license": "MIT",
@@ -36,6 +36,21 @@
36
36
  "scripts/debug-sdk-events.d.mts",
37
37
  "scripts/debug-provider-events.mjs",
38
38
  "scripts/debug-provider-events.d.mts",
39
+ "platform-smoke.config.mjs",
40
+ "scripts/platform-smoke.mjs",
41
+ "scripts/platform-smoke/assertions.mjs",
42
+ "scripts/platform-smoke/artifacts.mjs",
43
+ "scripts/platform-smoke/card-detect.mjs",
44
+ "scripts/platform-smoke/crabbox-runner.mjs",
45
+ "scripts/platform-smoke/doctor.mjs",
46
+ "scripts/platform-smoke/live-suite-runner.mjs",
47
+ "scripts/platform-smoke/jsonl-text.mjs",
48
+ "scripts/platform-smoke/platform-build-windows.ps1",
49
+ "scripts/platform-smoke/pty-capture.mjs",
50
+ "scripts/platform-smoke/render-ansi.mjs",
51
+ "scripts/platform-smoke/scenarios.mjs",
52
+ "scripts/platform-smoke/targets.mjs",
53
+ "scripts/platform-smoke/visual-evidence.mjs",
39
54
  "scripts/lib/cursor-cli-args.mjs",
40
55
  "scripts/lib/cursor-cli-args.d.mts",
41
56
  "scripts/lib/cursor-child-process.mjs",
@@ -54,9 +69,11 @@
54
69
  "docs/cursor-tool-surfaces.md",
55
70
  "docs/cursor-live-smoke-checklist.md",
56
71
  "docs/cursor-testing-lessons.md",
72
+ "docs/crabbox-platform-testing-lessons.md",
57
73
  "docs/cursor-dogfood-checklist.md",
58
74
  "docs/cursor-native-tool-replay.md",
59
75
  "docs/cursor-native-tool-visual-audit.md",
76
+ "docs/platform-smoke.md",
60
77
  "LICENSE",
61
78
  "CHANGELOG.md"
62
79
  ],
@@ -69,6 +86,7 @@
69
86
  "typecheck:src": "tsc --noEmit",
70
87
  "typecheck:tests": "tsc -p tsconfig.test.json --noEmit",
71
88
  "typecheck:replay-compile": "tsc --noEmit -p test/tsconfig.json",
89
+ "verify": "npm run check:platform-smoke && npm run typecheck && npm test",
72
90
  "test": "vitest run",
73
91
  "test:watch": "vitest",
74
92
  "refresh:cursor-snapshots": "node scripts/refresh-cursor-model-snapshots.mjs",
@@ -79,10 +97,17 @@
79
97
  "smoke:jsonl": "node scripts/validate-smoke-jsonl.mjs",
80
98
  "debug:sdk-events": "node scripts/debug-sdk-events.mjs",
81
99
  "debug:provider-events": "node scripts/debug-provider-events.mjs",
82
- "debug:mcp-coldstart": "node scripts/probe-mcp-coldstart.mjs"
100
+ "debug:mcp-coldstart": "node scripts/probe-mcp-coldstart.mjs",
101
+ "check:platform-smoke": "node --check scripts/platform-smoke.mjs && node --check scripts/platform-smoke/assertions.mjs && node --check scripts/platform-smoke/artifacts.mjs && node --check scripts/platform-smoke/card-detect.mjs && node --check scripts/platform-smoke/crabbox-runner.mjs && node --check scripts/platform-smoke/doctor.mjs && node --check scripts/platform-smoke/jsonl-text.mjs && node --check scripts/platform-smoke/live-suite-runner.mjs && node --check scripts/platform-smoke/pty-capture.mjs && node --check scripts/platform-smoke/render-ansi.mjs && node --check scripts/platform-smoke/scenarios.mjs && node --check scripts/platform-smoke/targets.mjs && node --check scripts/platform-smoke/visual-evidence.mjs && vitest run test/smoke-tooling.test.ts",
102
+ "smoke:platform": "node scripts/platform-smoke.mjs",
103
+ "smoke:platform:doctor": "node scripts/platform-smoke.mjs doctor",
104
+ "smoke:platform:macos": "node scripts/platform-smoke.mjs run --target macos",
105
+ "smoke:platform:ubuntu": "node scripts/platform-smoke.mjs run --target ubuntu",
106
+ "smoke:platform:windows-native": "node scripts/platform-smoke.mjs run --target windows-native",
107
+ "smoke:platform:all": "npm run smoke:platform:doctor && node scripts/platform-smoke.mjs run --target macos,ubuntu,windows-native"
83
108
  },
84
109
  "dependencies": {
85
- "@cursor/sdk": "1.0.16",
110
+ "@cursor/sdk": "1.0.17",
86
111
  "@modelcontextprotocol/sdk": "^1.29.0"
87
112
  },
88
113
  "peerDependencies": {
@@ -96,6 +121,7 @@
96
121
  "@earendil-works/pi-coding-agent": "0.78.0",
97
122
  "@earendil-works/pi-tui": "0.78.0",
98
123
  "@xterm/xterm": "^6.0.0",
124
+ "node-pty": "^1.1.0",
99
125
  "playwright": "^1.60.0",
100
126
  "typebox": "^1.1.38",
101
127
  "typescript": "^6.0.3",
@@ -109,5 +135,8 @@
109
135
  "overrides": {
110
136
  "undici": "7.25.0",
111
137
  "sqlite3": "6.0.1"
138
+ },
139
+ "allowScripts": {
140
+ "node-pty@1.1.0": true
112
141
  }
113
142
  }
@@ -0,0 +1,21 @@
1
+ // Platform smoke configuration for pi-cursor-sdk.
2
+ // Reusable across pi extensions: change package name, model IDs, scenarios, and card matrix only.
3
+
4
+ export default {
5
+ packageName: "pi-cursor-sdk",
6
+ cursorModel: "cursor/composer-2-5",
7
+ artifactRoot: ".artifacts/platform-smoke",
8
+ requiredTargets: ["macos", "ubuntu", "windows-native"],
9
+ requiredSuites: [
10
+ "platform-build",
11
+ "cursor-native-visual-matrix",
12
+ "cursor-bridge-visual-matrix",
13
+ "cursor-abort-cleanup",
14
+ ],
15
+ requiredCrabbox: {
16
+ install: "brew install crabbox",
17
+ version: "0.24.0",
18
+ },
19
+ ubuntuContainerImage: "cimg/node:24.16",
20
+ nodeValidationMajor: 24,
21
+ };
@@ -5,7 +5,7 @@
5
5
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { spawn } from "node:child_process";
7
7
  import { createRequire } from "node:module";
8
- import { dirname, join } from "node:path";
8
+ import { dirname, join, resolve } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import {
11
11
  apiKeySecretsFromProcess,
@@ -20,10 +20,17 @@ import { scrubSensitiveText } from "../shared/cursor-sensitive-text.mjs";
20
20
  import { createScriptFail } from "./lib/cursor-script-fail.mjs";
21
21
  import { serializeCursorSettingSources } from "../shared/cursor-setting-sources.mjs";
22
22
 
23
+ function isMainModule() {
24
+ if (!process.argv[1]) return false;
25
+ const current = fileURLToPath(import.meta.url);
26
+ const invoked = resolve(process.argv[1]);
27
+ return process.platform === "win32" ? current.toLowerCase() === invoked.toLowerCase() : current === invoked;
28
+ }
29
+
23
30
  const require = createRequire(import.meta.url);
24
31
  const root = fileURLToPath(new URL("..", import.meta.url));
25
32
  const packageJson = require("../package.json");
26
- const DEFAULT_MODEL = "cursor/composer-2.5";
33
+ const DEFAULT_MODEL = "cursor/composer-2-5";
27
34
  const DEFAULT_OUT_BASE = ".debug/cursor-sdk-events";
28
35
  const SDK_EVENT_DEBUG_LOG_PREFIX = "[pi-cursor-sdk:sdk-events]";
29
36
  const PI_SESSION_SNAPSHOT_ARTIFACT = "pi-session-snapshot.jsonl";
@@ -291,7 +298,7 @@ async function main(argv = process.argv.slice(2), env = process.env) {
291
298
  console.log(JSON.stringify(await runDebugProviderEvents(args, env)));
292
299
  }
293
300
 
294
- if (import.meta.url === new URL(process.argv[1], "file:").href) {
301
+ if (isMainModule()) {
295
302
  main().catch((error) => {
296
303
  fail(error instanceof Error ? error.message : String(error), apiKeySecretsFromProcess());
297
304
  });
@@ -5,7 +5,8 @@
5
5
  */
6
6
  import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { createRequire } from "node:module";
8
- import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { dirname, join, resolve } from "node:path";
9
10
  import {
10
11
  apiKeySecretsFromProcess,
11
12
  commonProbeFlags,
@@ -18,6 +19,13 @@ import {
18
19
  import { createScriptFail } from "./lib/cursor-script-fail.mjs";
19
20
  import { installCursorSdkOutputFilter, suppressCursorSdkOutput } from "./lib/cursor-sdk-output-filter.mjs";
20
21
 
22
+ function isMainModule() {
23
+ if (!process.argv[1]) return false;
24
+ const current = fileURLToPath(import.meta.url);
25
+ const invoked = resolve(process.argv[1]);
26
+ return process.platform === "win32" ? current.toLowerCase() === invoked.toLowerCase() : current === invoked;
27
+ }
28
+
21
29
  const require = createRequire(import.meta.url);
22
30
  const packageJson = require("../package.json");
23
31
 
@@ -343,7 +351,7 @@ async function main(argv = process.argv.slice(2), env = process.env) {
343
351
  await captureEvents(args);
344
352
  }
345
353
 
346
- if (import.meta.url === new URL(process.argv[1], "file:").href) {
354
+ if (isMainModule()) {
347
355
  main().catch((error) => {
348
356
  const message = error instanceof Error ? error.message : String(error);
349
357
  fail(message, apiKeySecretsFromProcess());
@@ -361,13 +361,13 @@ log "check: list-models"
361
361
  LIST_OUT="$ISOLATED/list-models.txt"
362
362
  run_in_dir_capture_combined "list-models" 30 "$PROJECT_DIR" "$LIST_OUT" "${PI_CURSOR_ENV[@]}" \
363
363
  "$PI_BIN" --cursor-no-fast --list-models cursor
364
- "$RG_BIN" -q "composer-2\\.5|composer-2-5" "$LIST_OUT" || fail "composer-2.5 not listed (see $LIST_OUT)"
364
+ "$RG_BIN" -q "composer-2\\.5|composer-2-5" "$LIST_OUT" || fail "composer-2-5 not listed (see $LIST_OUT)"
365
365
 
366
366
  log "check: basic provider prompt"
367
367
  BASIC_DIR="$SESSION_ROOT/basic"
368
368
  mkdir -p "$BASIC_DIR"
369
369
  run_in_dir_capture_split "basic prompt" "$PI_LIVE_TIMEOUT" "$PROJECT_DIR" "$ISOLATED/basic.stdout.txt" "$ISOLATED/basic.stderr.txt" "${PI_CURSOR_ENV[@]}" \
370
- "$PI_BIN" --cursor-no-fast --model cursor/composer-2.5 --session-dir "$BASIC_DIR" --no-tools -p 'Reply exactly: PI_CURSOR_ISOLATED_OK'
370
+ "$PI_BIN" --cursor-no-fast --model cursor/composer-2-5 --session-dir "$BASIC_DIR" --no-tools -p 'Reply exactly: PI_CURSOR_ISOLATED_OK'
371
371
  "$RG_BIN" -q "PI_CURSOR_ISOLATED_OK" "$ISOLATED/basic.stdout.txt" || fail "basic prompt missing PI_CURSOR_ISOLATED_OK"
372
372
  validate_replay_jsonl "$BASIC_DIR"
373
373
 
@@ -375,14 +375,14 @@ log "check: native replay"
375
375
  REPLAY_DIR="$SESSION_ROOT/native-replay"
376
376
  mkdir -p "$REPLAY_DIR"
377
377
  run_in_dir_capture_split "native replay" "$PI_LIVE_TIMEOUT" "$PROJECT_DIR" "$ISOLATED/replay.stdout.txt" "$ISOLATED/replay.stderr.txt" "${PI_CURSOR_ENV[@]}" PI_CURSOR_NATIVE_TOOL_DISPLAY=1 \
378
- "$PI_BIN" --cursor-no-fast --model cursor/composer-2.5 --session-dir "$REPLAY_DIR" -p 'Read ./README.md briefly, then answer README_SEEN=yes if it mentions pi-cursor-sdk.'
378
+ "$PI_BIN" --cursor-no-fast --model cursor/composer-2-5 --session-dir "$REPLAY_DIR" -p 'Read ./README.md briefly, then answer README_SEEN=yes if it mentions pi-cursor-sdk.'
379
379
  validate_replay_jsonl "$REPLAY_DIR"
380
380
 
381
381
  log "check: plan-strip shim (plan-mode execute reset)"
382
382
  PLAN_DIR="$SESSION_ROOT/plan-strip"
383
383
  mkdir -p "$PLAN_DIR"
384
384
  run_in_dir_capture_split "plan-strip replay" "$PI_LIVE_TIMEOUT" "$PROJECT_DIR" "$ISOLATED/plan.stdout.txt" "$ISOLATED/plan.stderr.txt" "${PI_CURSOR_ENV[@]}" PI_CURSOR_NATIVE_TOOL_DISPLAY=1 \
385
- "$PI_BIN" -e "$SHIM_DIR" --cursor-no-fast --model cursor/composer-2.5 --session-dir "$PLAN_DIR" -p 'After reset, read README.md and answer PLAN_STRIP_OK=yes.'
385
+ "$PI_BIN" -e "$SHIM_DIR" --cursor-no-fast --model cursor/composer-2-5 --session-dir "$PLAN_DIR" -p 'After reset, read README.md and answer PLAN_STRIP_OK=yes.'
386
386
  validate_replay_jsonl "$PLAN_DIR"
387
387
 
388
388
  log "PASS isolated install smoke: $ISOLATED"
@@ -91,6 +91,7 @@ try {
91
91
  });
92
92
  term.open(terminalElement);
93
93
  term.resize(${options.width}, ${options.height});
94
+ window.__piVisualSmokeTerminal = term;
94
95
  term.write(ansi, () => {
95
96
  document.body.setAttribute("data-render-ready", "true");
96
97
  });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Artifact management — directory layout, manifest, redaction scanning, packaging.
3
+ */
4
+
5
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, existsSync } from "node:fs";
6
+ import { resolve, relative, basename } from "node:path";
7
+
8
+ /** Create a suite artifact directory. */
9
+ export function createSuiteDir(artifactRoot, runId, targetName, suiteName) {
10
+ const dir = resolve(process.cwd(), artifactRoot, runId, targetName, suiteName);
11
+ mkdirSync(dir, { recursive: true });
12
+ return dir;
13
+ }
14
+
15
+ /** Write artifact-manifest.json. */
16
+ export function writeManifest(dir, expectedFiles) {
17
+ const actual = [];
18
+ function walk(d) {
19
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
20
+ const fp = resolve(d, entry.name);
21
+ if (entry.isDirectory()) walk(fp);
22
+ else if (entry.isFile()) actual.push(relative(dir, fp));
23
+ }
24
+ }
25
+ if (existsSync(dir)) walk(dir);
26
+
27
+ const manifest = {
28
+ expected: expectedFiles ?? [],
29
+ present: actual,
30
+ missing: (expectedFiles ?? []).filter(f => !actual.includes(f)),
31
+ writtenAt: new Date().toISOString(),
32
+ };
33
+ writeFileSync(resolve(dir, "artifact-manifest.json"), JSON.stringify(manifest, null, 2));
34
+ return manifest;
35
+ }
36
+
37
+ const SECRET_PATTERNS = [
38
+ [/Authorization:\s*Bearer\s+[A-Za-z0-9\-._~+/]{20,}=*/gi, "Authorization header", "Authorization: Bearer [REDACTED_BEARER_TOKEN]"],
39
+ [/(bearer\s+)[A-Za-z0-9\-._~+/]{20,}=*/gi, "bearer token", "$1[REDACTED_BEARER_TOKEN]"],
40
+ [/connect\.sid=[A-Za-z0-9%]+/gi, "session cookie", "connect.sid=[REDACTED_SESSION_COOKIE]"],
41
+ [/https?:\/\/[^/\s]*\/cursor-pi-tool-bridge\/[A-Za-z0-9_.:-]+\/mcp/gi, "bridge endpoint URL", "[REDACTED_BRIDGE_ENDPOINT_URL]"],
42
+ [/"(apiKey|accessToken|refreshToken|session|cookie)"\s*:\s*"[^"\s]{12,}"/gi, "auth/token JSON field", '"$1":"[REDACTED_SECRET]"'],
43
+ ];
44
+
45
+ /** Redact known secret material before writing logs/artifacts. */
46
+ export function redactSecrets(text) {
47
+ let redacted = String(text ?? "");
48
+ const cursorKey = process.env.CURSOR_API_KEY;
49
+ if (cursorKey && cursorKey.length > 10) {
50
+ redacted = redacted.split(cursorKey).join("[REDACTED_CURSOR_API_KEY]");
51
+ }
52
+ for (const [pattern, , replacement] of SECRET_PATTERNS) {
53
+ redacted = redacted.replace(pattern, replacement);
54
+ }
55
+ return redacted;
56
+ }
57
+
58
+ /** Scan text content for secrets. Returns array of violation descriptions. */
59
+ export function scanForSecrets(text) {
60
+ const violations = [];
61
+ const cursorKey = process.env.CURSOR_API_KEY;
62
+ if (cursorKey && cursorKey.length > 10 && String(text ?? "").includes(cursorKey)) {
63
+ violations.push("CURSOR_API_KEY literal found");
64
+ }
65
+ for (const [pattern, label] of SECRET_PATTERNS) {
66
+ pattern.lastIndex = 0;
67
+ if (pattern.test(String(text ?? ""))) violations.push(`potential ${label}`);
68
+ }
69
+ return violations;
70
+ }
71
+
72
+ /** Scan all text files in a directory for secrets. */
73
+ export function scanArtifacts(dir) {
74
+ const findings = [];
75
+ function walk(d) {
76
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
77
+ const fp = resolve(d, entry.name);
78
+ if (entry.isDirectory()) { walk(fp); continue; }
79
+ if (!entry.isFile()) continue;
80
+ const ext = entry.name.split(".").pop()?.toLowerCase() ?? "";
81
+ if (!["txt", "json", "jsonl", "md", "log", "ansi", "html", "yml", "yaml", "js", "mjs", "ts"].includes(ext)) continue;
82
+ try {
83
+ const content = readFileSync(fp, "utf-8");
84
+ const violations = scanForSecrets(content);
85
+ for (const v of violations) {
86
+ findings.push({ file: relative(dir, fp), violation: v });
87
+ }
88
+ } catch { /* binary or unreadable */ }
89
+ }
90
+ }
91
+ walk(dir);
92
+ return findings;
93
+ }
94
+
95
+ /** Write summary.json for a suite. */
96
+ export function writeSummary(dir, data) {
97
+ writeFileSync(resolve(dir, "summary.json"), JSON.stringify({
98
+ ...data,
99
+ writtenAt: new Date().toISOString(),
100
+ }, null, 2));
101
+ }
102
+
103
+ /** Write command.txt recording the command that was executed. */
104
+ export function writeCommand(dir, cmd) {
105
+ writeFileSync(resolve(dir, "command.txt"), Array.isArray(cmd) ? cmd.join(" ") + "\n" : cmd + "\n");
106
+ }
107
+
108
+ /** Write exit-code.txt. */
109
+ export function writeExitCode(dir, code, signal) {
110
+ writeFileSync(resolve(dir, "exit-code.txt"), `code=${code}\nsignal=${signal ?? "none"}\n`);
111
+ }
112
+
113
+ /** Package a directory as tar.gz (posix) or zip (powershell). */
114
+ export async function packageArtifacts(dir, archivePath) {
115
+ const { execSync } = await import("node:child_process");
116
+ const dirName = basename(dir);
117
+ const parentDir = resolve(dir, "..");
118
+ if (archivePath.endsWith(".tar.gz")) {
119
+ execSync(`tar -czf "${archivePath}" -C "${parentDir}" "${dirName}"`, { stdio: "pipe" });
120
+ } else if (archivePath.endsWith(".zip")) {
121
+ execSync(`cd "${parentDir}" && zip -r "${archivePath}" "${dirName}"`, { stdio: "pipe" });
122
+ }
123
+ return archivePath;
124
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Assertion engine — assertions.json, failures.md, JSONL parsing.
3
+ */
4
+
5
+ import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
6
+ import { resolve, basename } from "node:path";
7
+
8
+ /** Run a set of checks and write assertions.json. */
9
+ export function runAssertions(dir, checks) {
10
+ const results = [];
11
+ let allOk = true;
12
+ for (const check of checks) {
13
+ try {
14
+ const ok = check.fn();
15
+ results.push({ id: check.id, ok, ...(ok ? {} : { error: check.error }) });
16
+ if (!ok) allOk = false;
17
+ } catch (e) {
18
+ results.push({ id: check.id, ok: false, error: e.message });
19
+ allOk = false;
20
+ }
21
+ }
22
+
23
+ const assertions = {
24
+ ok: allOk,
25
+ checks: results,
26
+ writtenAt: new Date().toISOString(),
27
+ };
28
+
29
+ writeFileSync(resolve(dir, "assertions.json"), JSON.stringify(assertions, null, 2));
30
+
31
+ if (!allOk) {
32
+ const failures = results.filter(r => !r.ok);
33
+ const md = [
34
+ "# Assertion Failures",
35
+ "",
36
+ ...failures.map(f => `- **${f.id}**: ${f.error ?? "failed"}`),
37
+ "",
38
+ `Total: ${failures.length} failure(s)`,
39
+ ];
40
+ writeFileSync(resolve(dir, "failures.md"), md.join("\n") + "\n");
41
+ }
42
+
43
+ return assertions;
44
+ }
45
+
46
+ /** Parse a JSONL file into an array of objects. */
47
+ export function parseJSONL(path) {
48
+ if (!existsSync(path)) return [];
49
+ try {
50
+ const text = readFileSync(path, "utf-8");
51
+ return text.split("\n").filter(Boolean).map(line => {
52
+ try { return JSON.parse(line); } catch { return null; }
53
+ }).filter(Boolean);
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ /** Find a session JSONL file in a session directory. */
60
+ export function findSessionJSONL(sessionDir) {
61
+ if (!existsSync(sessionDir)) return null;
62
+ for (const entry of readdirSync(sessionDir, { withFileTypes: true })) {
63
+ if (entry.isDirectory()) {
64
+ const found = findSessionJSONL(resolve(sessionDir, entry.name));
65
+ if (found) return found;
66
+ }
67
+ if (entry.name.endsWith(".jsonl") && !entry.name.includes("events")) {
68
+ return resolve(sessionDir, entry.name);
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /** Simple check: does text contain a required substring? */
75
+ export function assertContains(text, substring, id) {
76
+ return { id, fn: () => text.includes(substring) };
77
+ }
78
+
79
+ /** Simple check: does a file exist? */
80
+ export function assertFileExists(path, id) {
81
+ return { id, fn: () => existsSync(path) };
82
+ }
83
+
84
+ /** Check JSONL for required tool calls. */
85
+ export function assertJSONLToolCalls(jsonlPath, expectedTools) {
86
+ return {
87
+ id: "jsonl-tool-calls",
88
+ fn: () => {
89
+ const events = parseJSONL(jsonlPath);
90
+ const toolCalls = events.filter(e => e.type === "tool_use" || e.toolCall);
91
+ for (const expected of expectedTools) {
92
+ const found = toolCalls.some(tc => {
93
+ const name = tc.toolCall?.name ?? tc.name ?? tc.tool_name ?? "";
94
+ return name === expected.name;
95
+ });
96
+ if (!found) return false;
97
+ }
98
+ return true;
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Card detector — records stable visible evidence regions from rendered terminal output.
3
+ *
4
+ * This is intentionally stricter than a raw text-marker search: prompt lines such as
5
+ * "call pi__read" must not satisfy card assertions. Per-card screenshots come from
6
+ * visual-evidence.mjs; this module writes the legacy cards.json/index.html inventory.
7
+ */
8
+
9
+ import { writeFileSync, mkdirSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+
12
+ const CARD_PATTERNS = [
13
+ { id: "read", pattern: /^\s*read (?:\.\/)?package\.json\s*$/i },
14
+ { id: "grep", pattern: /^\s*grep \/pi-cursor-sdk\/ in README\.md\s*$/i },
15
+ { id: "find", pattern: /^\s*find README\.md in\s+\S+/i },
16
+ { id: "list", pattern: /^\s*(?:find \* in src|find src\/\* in \.|Get-ChildItem -Name \.\/src)\s*/i },
17
+ { id: "shell-success", pattern: /^\s*cursor visual smoke\s*$/i },
18
+ { id: "write", pattern: /^\s*\+.*beta\s*$/i },
19
+ { id: "edit-diff", pattern: /^\s*\+.*gamma\s*$/i },
20
+ { id: "shell-failure", pattern: /^\s*(?:native shell failure|Command exited with code 7)\s*$/i },
21
+ { id: "bridge-read-success", pattern: /^\s*read \.\/package\.json\s*$/i },
22
+ { id: "bridge-read-failure", pattern: /^\s*(?:read \.\/definitely-missing-platform-smoke-file\.txt|ENOENT: no such file)\s*/i },
23
+ { id: "bridge-shell-success", pattern: /^\s*bridge visual smoke\s*$/i },
24
+ { id: "footer-status", pattern: /\bcomposer-2-5\b|\bcomposer-2\.5\b/i },
25
+ ];
26
+
27
+ function cleanLine(line) {
28
+ return line
29
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
30
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
31
+ .replace(/\r/g, "");
32
+ }
33
+
34
+ /**
35
+ * Detect stable rendered evidence regions in terminal text.
36
+ *
37
+ * Returns an array of { id, label, startLine, endLine }.
38
+ */
39
+ export function detectCards(txtContent) {
40
+ const lines = txtContent.split("\n").map(cleanLine);
41
+ const cards = [];
42
+ const seen = new Set();
43
+
44
+ for (let i = 0; i < lines.length; i++) {
45
+ for (const card of CARD_PATTERNS) {
46
+ if (seen.has(card.id)) continue;
47
+ card.pattern.lastIndex = 0;
48
+ if (!card.pattern.test(lines[i])) continue;
49
+ seen.add(card.id);
50
+ cards.push({
51
+ id: card.id,
52
+ label: card.id,
53
+ startLine: i,
54
+ endLine: i,
55
+ });
56
+ }
57
+ }
58
+
59
+ return cards;
60
+ }
61
+
62
+ /** Write cards.json and cards/index.html gallery. */
63
+ export function writeCardArtifacts(dir, cards) {
64
+ mkdirSync(resolve(dir, "cards"), { recursive: true });
65
+
66
+ const cardsData = cards.map(c => ({
67
+ id: c.id,
68
+ label: c.label,
69
+ startLine: c.startLine,
70
+ endLine: c.endLine,
71
+ lineCount: c.endLine - c.startLine + 1,
72
+ }));
73
+
74
+ writeFileSync(resolve(dir, "cards", "cards.json"), JSON.stringify(cardsData, null, 2));
75
+
76
+ const html = `<!DOCTYPE html>
77
+ <html lang="en">
78
+ <head><meta charset="utf-8"><title>Cards Gallery</title>
79
+ <style>
80
+ body { font-family: monospace; background: #1e1e1e; color: #d4d4d4; padding: 20px; }
81
+ .card { border: 1px solid #444; margin: 8px 0; padding: 8px; }
82
+ .card h3 { margin: 0 0 4px 0; color: #569cd6; }
83
+ .card .meta { color: #888; font-size: 12px; }
84
+ </style></head><body>
85
+ <h1>Cards Gallery</h1>
86
+ ${cardsData.map(c => `<div class="card"><h3>${c.label}</h3><div class="meta">line ${c.startLine}</div></div>`).join("\n")}
87
+ </body></html>`;
88
+
89
+ writeFileSync(resolve(dir, "cards", "index.html"), html);
90
+ }
91
+
92
+ /** Assert that required cards are present as distinct exact evidence ids. */
93
+ export function assertRequiredCards(_dir, detectedCards, requiredCards) {
94
+ const detectedIds = new Set(detectedCards.map(c => c.id));
95
+ return requiredCards.map((req) => ({ id: `card-${req}`, ok: detectedIds.has(req) }));
96
+ }