@jevitate/cli 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/LICENSE +21 -0
- package/dist/ai-cli.d.ts +31 -0
- package/dist/ai-cli.js +238 -0
- package/dist/ai-cli.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +50663 -0
- package/dist/bin.js.map +1 -0
- package/dist/data-dir.d.ts +9 -0
- package/dist/data-dir.js +13 -0
- package/dist/data-dir.js.map +1 -0
- package/dist/envelope.d.ts +11 -0
- package/dist/envelope.js +7 -0
- package/dist/envelope.js.map +1 -0
- package/dist/explore-api.d.ts +187 -0
- package/dist/explore-api.js +302 -0
- package/dist/explore-api.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/init-keys.d.ts +14 -0
- package/dist/init-keys.js +18 -0
- package/dist/init-keys.js.map +1 -0
- package/dist/init-mcp.d.ts +87 -0
- package/dist/init-mcp.js +198 -0
- package/dist/init-mcp.js.map +1 -0
- package/dist/init-skills.d.ts +80 -0
- package/dist/init-skills.js +256 -0
- package/dist/init-skills.js.map +1 -0
- package/dist/journey-api.d.ts +37 -0
- package/dist/journey-api.js +55 -0
- package/dist/journey-api.js.map +1 -0
- package/dist/load-api.d.ts +48 -0
- package/dist/load-api.js +84 -0
- package/dist/load-api.js.map +1 -0
- package/dist/mcp-api.d.ts +116 -0
- package/dist/mcp-api.js +359 -0
- package/dist/mcp-api.js.map +1 -0
- package/dist/mission-api.d.ts +56 -0
- package/dist/mission-api.js +65 -0
- package/dist/mission-api.js.map +1 -0
- package/dist/program.d.ts +77 -0
- package/dist/program.js +1938 -0
- package/dist/program.js.map +1 -0
- package/dist/record-api.d.ts +74 -0
- package/dist/record-api.js +93 -0
- package/dist/record-api.js.map +1 -0
- package/dist/regression-api.d.ts +17 -0
- package/dist/regression-api.js +16 -0
- package/dist/regression-api.js.map +1 -0
- package/dist/self-heal-adapter.d.ts +31 -0
- package/dist/self-heal-adapter.js +70 -0
- package/dist/self-heal-adapter.js.map +1 -0
- package/dist/source-api.d.ts +147 -0
- package/dist/source-api.js +208 -0
- package/dist/source-api.js.map +1 -0
- package/dist/source-run-api.d.ts +57 -0
- package/dist/source-run-api.js +91 -0
- package/dist/source-run-api.js.map +1 -0
- package/dist/ui-api.d.ts +35 -0
- package/dist/ui-api.js +369 -0
- package/dist/ui-api.js.map +1 -0
- package/dist/ux-api.d.ts +75 -0
- package/dist/ux-api.js +231 -0
- package/dist/ux-api.js.map +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +37 -0
- package/dist/version.js.map +1 -0
- package/package.json +85 -0
- package/skills/jevitate-explore/SKILL.md +108 -0
- package/skills/jevitate-load-test/SKILL.md +42 -0
- package/skills/jevitate-mission-scope/SKILL.md +107 -0
- package/skills/jevitate-record/SKILL.md +78 -0
- package/skills/jevitate-run-journey/SKILL.md +72 -0
- package/skills/jevitate-sources/SKILL.md +88 -0
- package/skills/jevitate-ux-review/SKILL.md +68 -0
- package/ui/app.js +538 -0
- package/ui/index.html +546 -0
package/dist/program.js
ADDED
|
@@ -0,0 +1,1938 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { tmpdir, userInfo } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import * as clack from "@clack/prompts";
|
|
7
|
+
import { SitePolicySchema, simulateTiming } from "@jevitate/domain";
|
|
8
|
+
import { openDatabase, migrateToLatest, SqliteSitePolicyRepository } from "@jevitate/storage-sqlite";
|
|
9
|
+
import { RecordingSchema, AuthoringTakeSchema, PostdocDecisionsSchema, promoteToVariable, diffTakes, applyPostdoc, flattenBaseFillSteps, fitInteractionPolicy, } from "@jevitate/recording";
|
|
10
|
+
import { FsJourneyStore, JourneyRegistry, ParamValidationError } from "@jevitate/journey";
|
|
11
|
+
import { envCredentialStore, requireKeys, MissingCredentialError, FakeGenerationGateway, OpenRouterGenerationGateway, JevJudgmentGateway, } from "@jevitate/ai-core";
|
|
12
|
+
import { UnauthorizedExploreTargetError } from "@jevitate/explore";
|
|
13
|
+
import { PlaywrightBrowserPort } from "@jevitate/playwright";
|
|
14
|
+
import { CastActor, BrowseTheWeb } from "@jevitate/screenplay";
|
|
15
|
+
import { safeRunPolicy } from "@jevitate/domain";
|
|
16
|
+
import { makeExploreSelfHealer } from "./self-heal-adapter.js";
|
|
17
|
+
import { ok, fail } from "./envelope.js";
|
|
18
|
+
import { runJourneyProgrammatically, UnknownJourneyError } from "./journey-api.js";
|
|
19
|
+
import { runJourneyLoadTest, UnknownLoadJourneyError } from "./load-api.js";
|
|
20
|
+
import { runRegressionCapture } from "./regression-api.js";
|
|
21
|
+
import { addMissionTarget, listMissionTargets, promoteMissionTarget, missionTargetContext, UnknownMissionTargetError, } from "./mission-api.js";
|
|
22
|
+
import { startMcpServer } from "./mcp-api.js";
|
|
23
|
+
import { startUiServer } from "./ui-api.js";
|
|
24
|
+
import { registerAiCommands, realSecureIO } from "./ai-cli.js";
|
|
25
|
+
import { collectAllMissingKeys } from "./init-keys.js";
|
|
26
|
+
import { readCliVersion } from "./version.js";
|
|
27
|
+
import { detectRuntimes, resolveInstallTargetPaths, installSkills, } from "./init-skills.js";
|
|
28
|
+
import { registerMcp, resolveMcpTargetPaths, renderPrintConfig, } from "./init-mcp.js";
|
|
29
|
+
import { loadManifest } from "@jevitate/skills";
|
|
30
|
+
import { runExploration, runAuthorJourney, runCoverageMission, runAdversarialCliMission, runFeatureCliMission, parseAssertionSpec, resolveExploreAllowlist, } from "./explore-api.js";
|
|
31
|
+
import { runUsabilityMission, runUxReview, UxAnalysisFailedError } from "./ux-api.js";
|
|
32
|
+
import { resolveDataDir } from "./data-dir.js";
|
|
33
|
+
import { runRecording, resolveRecordAllowlist, } from "./record-api.js";
|
|
34
|
+
import { addSource, listSources, pullSource, updateSource, removeSource, trustJourney, publishJourneyToSource, realGhPort, NotPromotedError, NoDeclaredOriginsError, } from "./source-api.js";
|
|
35
|
+
import { runSourceJourney, realResolvedJourneyRunner, } from "./source-run-api.js";
|
|
36
|
+
import { FsTrustStore, FsAckStore, UnknownSourceError, EmbeddedSecretError, UndeclaredOriginError, UndeclaredTouError, HashMismatchError, UntrustedRiskyJourneyError, SourceValidationError, DEFAULT_LOCK_PATH, } from "@jevitate/sources";
|
|
37
|
+
// `~/.jevitate/*` is the product's runtime-data convention (product = Jevitate).
|
|
38
|
+
// See data-dir.ts.
|
|
39
|
+
const DEFAULT_DB_PATH = resolveDataDir(["db.sqlite"]);
|
|
40
|
+
const DEFAULT_JOURNEYS_DIR = resolveDataDir(["journeys"]);
|
|
41
|
+
const DEFAULT_REGRESSIONS_DIR = resolveDataDir(["regressions"]);
|
|
42
|
+
const DEFAULT_MISSION_TARGETS_DIR = resolveDataDir(["missions", "targets"]);
|
|
43
|
+
const DEFAULT_INBOX_DIR = resolveDataDir(["inbox"]);
|
|
44
|
+
function resolveDbPath(deps, flag) {
|
|
45
|
+
return flag ?? deps.dbPath ?? DEFAULT_DB_PATH;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Mirrors `resolveDbPath`'s flag > deps > home-dir-default convention: a
|
|
49
|
+
* per-invocation `--dir` flag wins, then a `CliDeps.journeysDir` wired in by
|
|
50
|
+
* the host, then `~/.jevitate/journeys`.
|
|
51
|
+
*/
|
|
52
|
+
function resolveJourneysDir(deps, flag) {
|
|
53
|
+
return flag ?? deps.journeysDir ?? DEFAULT_JOURNEYS_DIR;
|
|
54
|
+
}
|
|
55
|
+
/** Same flag > home-dir-default convention as `resolveJourneysDir`, for the
|
|
56
|
+
* committed-regressions directory `regression capture` writes into. */
|
|
57
|
+
function resolveRegressionsDir(flag) {
|
|
58
|
+
return flag ?? DEFAULT_REGRESSIONS_DIR;
|
|
59
|
+
}
|
|
60
|
+
/** Same flag > deps > home-dir-default convention as `resolveJourneysDir`, for
|
|
61
|
+
* the mission-targets store `mission target ...` reads/writes. */
|
|
62
|
+
function resolveMissionTargetsDir(deps, flag) {
|
|
63
|
+
return flag ?? deps.missionTargetsDir ?? DEFAULT_MISSION_TARGETS_DIR;
|
|
64
|
+
}
|
|
65
|
+
/** Same flag > deps > home-dir-default convention as `resolveJourneysDir`, for
|
|
66
|
+
* the inbox store `jevitate mcp`'s inbox tools AND `jevitate ui` both read
|
|
67
|
+
* from — the SAME resolved directory, so the two commands agree on where
|
|
68
|
+
* approvals/handbacks/reviews live. */
|
|
69
|
+
function resolveInboxDir(deps, flag) {
|
|
70
|
+
return flag ?? deps.inboxDir ?? DEFAULT_INBOX_DIR;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Assembles the injected `SourceApiDeps` for the distributed-sources commands
|
|
74
|
+
* from `CliDeps.sources` (test-injected ports) or the real production
|
|
75
|
+
* defaults: `~/.jevitate/sources` clones, `<cwd>/jevitate.lock`, and the
|
|
76
|
+
* local, per-user `FsTrustStore`/`FsAckStore` under `~/.jevitate/trust`.
|
|
77
|
+
* Trust/ack stores are LOCAL by design (§14.1) — never the team-shared lock.
|
|
78
|
+
*/
|
|
79
|
+
function resolveSourceApiDeps(deps) {
|
|
80
|
+
const s = deps.sources ?? {};
|
|
81
|
+
return {
|
|
82
|
+
sourcesDir: s.sourcesDir ?? resolveDataDir(["sources"]),
|
|
83
|
+
lockPath: s.lockPath ?? DEFAULT_LOCK_PATH(),
|
|
84
|
+
trust: new FsTrustStore(s.trustDir ?? resolveDataDir(["trust"])),
|
|
85
|
+
ack: new FsAckStore(s.ackDir ?? resolveDataDir(["trust", "acks"])),
|
|
86
|
+
git: s.git,
|
|
87
|
+
now: s.now,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** The identity recorded in a `TrustRecord`/`TouAck` — an injected value, else
|
|
91
|
+
* the OS username, else `"local"`. Never a secret/credential. */
|
|
92
|
+
function resolveApprovedBy(deps) {
|
|
93
|
+
if (deps.sources?.approvedBy)
|
|
94
|
+
return deps.sources.approvedBy;
|
|
95
|
+
try {
|
|
96
|
+
return userInfo().username || "local";
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return "local";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Builds a fresh, real Playwright-backed `Actor` (its own temp profile dir +
|
|
104
|
+
* browser context, per `journey-api.ts`'s `runJourneyProgrammatically`
|
|
105
|
+
* pattern) and returns it alongside a `close()` to tear the session down.
|
|
106
|
+
* `@jevitate/regression`'s `makeActor: () => Promise<Actor>` contract calls
|
|
107
|
+
* this once per reproduce/minimize attempt — a Playwright session cannot be
|
|
108
|
+
* reused after a run — so callers must close each one it hands back.
|
|
109
|
+
*/
|
|
110
|
+
async function makeRealBrowserActor(site) {
|
|
111
|
+
const profileDir = await mkdtemp(join(tmpdir(), "jevitate-regression-"));
|
|
112
|
+
const port = new PlaywrightBrowserPort();
|
|
113
|
+
const session = await port.open({ profileDir, headless: true, allowedOrigins: [site], baseUrl: site });
|
|
114
|
+
const actor = CastActor.named("regression-capture").whoCan(new BrowseTheWeb(session, [site]));
|
|
115
|
+
return {
|
|
116
|
+
actor,
|
|
117
|
+
close: async () => {
|
|
118
|
+
await session.close();
|
|
119
|
+
await rm(profileDir, { recursive: true, force: true });
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Parses repeated `--param key=value` flags into a `Record<string,string>`.
|
|
125
|
+
* `previous` starts as the option's default (`{}`) and this is called once
|
|
126
|
+
* per occurrence, commander's standard "collect" pattern.
|
|
127
|
+
*/
|
|
128
|
+
function collectParam(value, previous) {
|
|
129
|
+
const idx = value.indexOf("=");
|
|
130
|
+
const key = idx === -1 ? value : value.slice(0, idx);
|
|
131
|
+
const val = idx === -1 ? "" : value.slice(idx + 1);
|
|
132
|
+
return { ...previous, [key]: val };
|
|
133
|
+
}
|
|
134
|
+
function makeClock() {
|
|
135
|
+
return {
|
|
136
|
+
nowIso: () => new Date().toISOString(),
|
|
137
|
+
monotonicMs: () => Date.now(),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
async function withSitePolicyRepository(dbPath, fn) {
|
|
141
|
+
await mkdir(dirname(dbPath), { recursive: true });
|
|
142
|
+
const db = openDatabase(dbPath);
|
|
143
|
+
try {
|
|
144
|
+
await migrateToLatest(db);
|
|
145
|
+
const repository = new SqliteSitePolicyRepository(db, makeClock());
|
|
146
|
+
return await fn(repository);
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
await db.destroy();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function isPlannedStep(value) {
|
|
153
|
+
if (typeof value !== "object" || value === null)
|
|
154
|
+
return false;
|
|
155
|
+
const record = value;
|
|
156
|
+
if (typeof record.label !== "string")
|
|
157
|
+
return false;
|
|
158
|
+
switch (record.kind) {
|
|
159
|
+
case "type":
|
|
160
|
+
return typeof record.text === "string";
|
|
161
|
+
case "click":
|
|
162
|
+
case "navigate":
|
|
163
|
+
return true;
|
|
164
|
+
case "read":
|
|
165
|
+
return typeof record.chars === "number";
|
|
166
|
+
default:
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function parsePlannedScript(raw) {
|
|
171
|
+
const parsed = JSON.parse(raw);
|
|
172
|
+
if (!Array.isArray(parsed) || !parsed.every(isPlannedStep)) {
|
|
173
|
+
throw new Error("script must be a JSON array of planned steps ({kind, label, ...})");
|
|
174
|
+
}
|
|
175
|
+
return parsed;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Writes a JSON envelope using the program's CURRENTLY-CONFIGURED output
|
|
179
|
+
* writer (read at call time via `configureOutput()`), so tests that call
|
|
180
|
+
* `program.configureOutput({ writeOut })` after `buildProgram()` still see
|
|
181
|
+
* output routed to their writer. Also sets `process.exitCode` (0 for `ok`,
|
|
182
|
+
* 1 for `fail`) instead of hard-exiting, so `exitOverride()` in tests works.
|
|
183
|
+
*/
|
|
184
|
+
function emitJson(program, envelope) {
|
|
185
|
+
const writeOut = program.configureOutput().writeOut;
|
|
186
|
+
writeOut?.(`${JSON.stringify(envelope)}\n`);
|
|
187
|
+
process.exitCode = envelope.ok ? 0 : 1;
|
|
188
|
+
}
|
|
189
|
+
export function buildProgram(deps) {
|
|
190
|
+
const program = new Command();
|
|
191
|
+
program.name("jevitate").description("Local browser automation platform").version(readCliVersion());
|
|
192
|
+
program
|
|
193
|
+
.command("init")
|
|
194
|
+
.option("--json", "emit a JSON envelope")
|
|
195
|
+
.option("--skip-keys", "skip credential collection")
|
|
196
|
+
.option("--skip-skills", "skip skill installation")
|
|
197
|
+
.option("--skip-mcp", "skip registering the jevitate MCP server in detected harnesses")
|
|
198
|
+
.option("--targets <ids>", "comma-separated runtime ids to force-install to, overriding detection")
|
|
199
|
+
.option("--force", "overwrite a user-modified installed skill file/block or MCP config entry")
|
|
200
|
+
.option("--dry-run", "report planned skill-install/mcp-register actions without writing")
|
|
201
|
+
.action(async function () {
|
|
202
|
+
const { json, skipKeys, skipSkills, skipMcp, targets, force, dryRun } = this.opts();
|
|
203
|
+
try {
|
|
204
|
+
const data = { initialized: true };
|
|
205
|
+
if (!skipKeys) {
|
|
206
|
+
// SECURITY: reuses the existing, already-guardrailed credential
|
|
207
|
+
// collection. The report holds only key NAMES (required/collected),
|
|
208
|
+
// never a value — nothing here reads, echoes, logs, or returns a key.
|
|
209
|
+
const store = envCredentialStore(deps.ai?.env ?? process.env, deps.ai?.localConfig ?? {});
|
|
210
|
+
const io = deps.ai?.secureIO ?? realSecureIO();
|
|
211
|
+
data.keys = await collectAllMissingKeys(store, io);
|
|
212
|
+
}
|
|
213
|
+
// Explicit `--targets` overrides detection entirely (the user takes
|
|
214
|
+
// full control); otherwise `detectRuntimes` decides, always including
|
|
215
|
+
// the always-on generic fallback. Shared by the skill install and the
|
|
216
|
+
// MCP registration so a single selection drives both.
|
|
217
|
+
const runtimes = targets
|
|
218
|
+
? targets.split(",").map((t) => t.trim()).filter((t) => t.length > 0)
|
|
219
|
+
: detectRuntimes(deps.init?.detection);
|
|
220
|
+
if (!skipSkills) {
|
|
221
|
+
const paths = resolveInstallTargetPaths(deps.init?.detection);
|
|
222
|
+
const statePath = deps.init?.statePath ?? resolveDataDir(["skills-install-state.json"]);
|
|
223
|
+
const skills = loadManifest();
|
|
224
|
+
data.skills = await installSkills(runtimes, skills, paths, statePath, { force, dryRun });
|
|
225
|
+
}
|
|
226
|
+
if (!skipMcp) {
|
|
227
|
+
// Register the `jevitate mcp` server for each detected/selected
|
|
228
|
+
// harness, with the SAME never-clobber safety as skills: a user's
|
|
229
|
+
// conflicting or unparseable config is never overwritten without
|
|
230
|
+
// --force; each declined target reports a printable instruction
|
|
231
|
+
// instead (honest, never corrupts a config). `generic` has no MCP
|
|
232
|
+
// convention and is skipped inside `registerMcp`.
|
|
233
|
+
const mcpPaths = resolveMcpTargetPaths(deps.init?.detection);
|
|
234
|
+
data.mcp = await registerMcp(runtimes, mcpPaths, { force, dryRun });
|
|
235
|
+
}
|
|
236
|
+
const envelope = ok(data);
|
|
237
|
+
if (json) {
|
|
238
|
+
emitJson(program, envelope);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
const out = program.configureOutput().writeOut;
|
|
242
|
+
out?.("jevitate initialized\n");
|
|
243
|
+
if (data.keys)
|
|
244
|
+
out?.(`keys: ${JSON.stringify(data.keys)}\n`);
|
|
245
|
+
if (data.skills)
|
|
246
|
+
out?.(`skills: ${data.skills.length} target/skill pairs processed\n`);
|
|
247
|
+
if (data.mcp)
|
|
248
|
+
out?.(`mcp: ${data.mcp.length} harness config(s) processed\n`);
|
|
249
|
+
process.exitCode = 0;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
emitJson(program, fail("E_INIT", String(err instanceof Error ? err.message : err)));
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
const profile = program.command("profile");
|
|
257
|
+
profile
|
|
258
|
+
.command("create <name>")
|
|
259
|
+
.option("--json", "emit a JSON envelope")
|
|
260
|
+
.action(async function (name) {
|
|
261
|
+
const { json } = this.opts();
|
|
262
|
+
try {
|
|
263
|
+
const status = await deps.profiles.create(name);
|
|
264
|
+
const envelope = ok(status);
|
|
265
|
+
if (json) {
|
|
266
|
+
emitJson(program, envelope);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
program.configureOutput().writeOut?.(`profile '${status.name}' created at ${status.dir}\n`);
|
|
270
|
+
process.exitCode = 0;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
emitJson(program, fail("E_PROFILE_CREATE", String(err)));
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
profile
|
|
278
|
+
.command("status <name>")
|
|
279
|
+
.option("--json", "emit a JSON envelope")
|
|
280
|
+
.action(async function (name) {
|
|
281
|
+
const { json } = this.opts();
|
|
282
|
+
try {
|
|
283
|
+
const status = await deps.profiles.status(name);
|
|
284
|
+
const envelope = ok(status);
|
|
285
|
+
if (json) {
|
|
286
|
+
emitJson(program, envelope);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
program.configureOutput().writeOut?.(`profile '${status.name}': ${status.exists ? "exists" : "missing"} (${status.dir})\n`);
|
|
290
|
+
process.exitCode = 0;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
emitJson(program, fail("E_PROFILE_STATUS", String(err)));
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
const site = program.command("site");
|
|
298
|
+
const sitePolicy = site.command("policy");
|
|
299
|
+
sitePolicy
|
|
300
|
+
.command("get <site>")
|
|
301
|
+
.option("--account <account>", "account id", "primary")
|
|
302
|
+
.option("--db <path>", "sqlite db path")
|
|
303
|
+
.option("--json", "emit a JSON envelope")
|
|
304
|
+
.action(async function (siteId) {
|
|
305
|
+
const { account, db, json } = this.opts();
|
|
306
|
+
try {
|
|
307
|
+
const dbPath = resolveDbPath(deps, db);
|
|
308
|
+
const policy = await withSitePolicyRepository(dbPath, (repository) => repository.get(siteId, account));
|
|
309
|
+
const envelope = ok(policy);
|
|
310
|
+
if (json) {
|
|
311
|
+
emitJson(program, envelope);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
if (policy) {
|
|
315
|
+
program.configureOutput().writeOut?.(`policy for '${siteId}' (version ${policy.version}): ${JSON.stringify(policy)}\n`);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
program.configureOutput().writeOut?.(`no policy configured for '${siteId}' (account '${account}')\n`);
|
|
319
|
+
}
|
|
320
|
+
process.exitCode = 0;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
emitJson(program, fail("E_SITE_POLICY_GET", String(err)));
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
sitePolicy
|
|
328
|
+
.command("set <site>")
|
|
329
|
+
.requiredOption("--file <path>", "path to a policy JSON file")
|
|
330
|
+
.option("--account <account>", "account id", "primary")
|
|
331
|
+
.option("--db <path>", "sqlite db path")
|
|
332
|
+
.option("--json", "emit a JSON envelope")
|
|
333
|
+
.action(async function (siteId) {
|
|
334
|
+
const { account, db, file, json } = this.opts();
|
|
335
|
+
let policy;
|
|
336
|
+
try {
|
|
337
|
+
const raw = await readFile(file, "utf8");
|
|
338
|
+
policy = SitePolicySchema.parse(JSON.parse(raw));
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
emitJson(program, fail("E_INVALID_POLICY", String(err)));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
const dbPath = resolveDbPath(deps, db);
|
|
346
|
+
await withSitePolicyRepository(dbPath, (repository) => repository.set(siteId, account, policy));
|
|
347
|
+
const envelope = ok({ site: siteId, account, version: policy.version });
|
|
348
|
+
if (json) {
|
|
349
|
+
emitJson(program, envelope);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
program.configureOutput().writeOut?.(`policy for '${siteId}' (account '${account}') set to version ${policy.version}\n`);
|
|
353
|
+
process.exitCode = 0;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
emitJson(program, fail("E_SITE_POLICY_SET", String(err)));
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
site
|
|
361
|
+
.command("simulate <site>")
|
|
362
|
+
.requiredOption("--script <path>", "path to a planned-step script JSON file")
|
|
363
|
+
.option("--seed <n>", "deterministic RNG seed", "0")
|
|
364
|
+
.option("--account <account>", "account id", "primary")
|
|
365
|
+
.option("--db <path>", "sqlite db path")
|
|
366
|
+
.option("--json", "emit a JSON envelope")
|
|
367
|
+
.action(async function (siteId) {
|
|
368
|
+
const { account, db, script, seed, json } = this.opts();
|
|
369
|
+
let plannedScript;
|
|
370
|
+
try {
|
|
371
|
+
const raw = await readFile(script, "utf8");
|
|
372
|
+
plannedScript = parsePlannedScript(raw);
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
emitJson(program, fail("E_INVALID_SCRIPT", String(err)));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const seedNum = Number(seed);
|
|
379
|
+
if (!Number.isFinite(seedNum)) {
|
|
380
|
+
emitJson(program, fail("E_INVALID_SEED", `--seed must be a finite number, got ${JSON.stringify(seed)}`));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
const dbPath = resolveDbPath(deps, db);
|
|
385
|
+
const policy = await withSitePolicyRepository(dbPath, (repository) => repository.get(siteId, account));
|
|
386
|
+
const interaction = policy?.interaction ?? {};
|
|
387
|
+
const profile = simulateTiming(interaction, seedNum, plannedScript);
|
|
388
|
+
const envelope = ok(profile);
|
|
389
|
+
if (json) {
|
|
390
|
+
emitJson(program, envelope);
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
const out = program.configureOutput().writeOut;
|
|
394
|
+
for (const step of profile.steps) {
|
|
395
|
+
out?.(`${step.kind} '${step.label}': ${step.delayMs}ms\n`);
|
|
396
|
+
}
|
|
397
|
+
out?.(`totalMs: ${profile.totalMs}\n`);
|
|
398
|
+
process.exitCode = 0;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
emitJson(program, fail("E_SITE_SIMULATE", String(err)));
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
const recording = program.command("recording");
|
|
406
|
+
recording
|
|
407
|
+
.command("promote <file>")
|
|
408
|
+
.requiredOption("--page <n>", "page index")
|
|
409
|
+
.requiredOption("--step <n>", "step index within the page")
|
|
410
|
+
.requiredOption("--var <name>", "variable name to bind")
|
|
411
|
+
.action(async function (file) {
|
|
412
|
+
const { page, step, var: varName } = this.opts();
|
|
413
|
+
try {
|
|
414
|
+
const raw = await readFile(file, "utf8");
|
|
415
|
+
const rec = RecordingSchema.parse(JSON.parse(raw));
|
|
416
|
+
const result = promoteToVariable(rec, { page: Number(page), step: Number(step) }, varName);
|
|
417
|
+
program.configureOutput().writeOut?.(`${JSON.stringify(result, null, 2)}\n`);
|
|
418
|
+
process.exitCode = 0;
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
emitJson(program, fail("E_INVALID_RECORDING", String(err)));
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
recording
|
|
425
|
+
.command("diff <takeA> <takeB> [more...]")
|
|
426
|
+
.option("--json", "emit a JSON envelope")
|
|
427
|
+
.action(async function (takeA, takeB, more) {
|
|
428
|
+
const { json } = this.opts();
|
|
429
|
+
try {
|
|
430
|
+
const files = [takeA, takeB, ...more];
|
|
431
|
+
const takes = await Promise.all(files.map(async (f) => {
|
|
432
|
+
const raw = await readFile(f, "utf8");
|
|
433
|
+
const parsed = AuthoringTakeSchema.parse(JSON.parse(raw));
|
|
434
|
+
return { recording: parsed.recording, values: new Map(Object.entries(parsed.values)) };
|
|
435
|
+
}));
|
|
436
|
+
const diffResult = diffTakes(takes);
|
|
437
|
+
const envelope = ok(diffResult);
|
|
438
|
+
if (json) {
|
|
439
|
+
emitJson(program, envelope);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
const out = program.configureOutput().writeOut;
|
|
443
|
+
diffResult.columns.forEach((col, i) => {
|
|
444
|
+
const type = col.inferredType ? `, type=${col.inferredType}` : "";
|
|
445
|
+
out?.(`column ${i}: ${col.kind} (confidence ${col.confidence.toFixed(2)}${type}) values=${JSON.stringify(col.values)}\n`);
|
|
446
|
+
});
|
|
447
|
+
process.exitCode = 0;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
emitJson(program, fail("E_INVALID_TAKE", String(err)));
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
recording
|
|
455
|
+
.command("fit <file>")
|
|
456
|
+
.option("--json", "emit a JSON envelope")
|
|
457
|
+
.action(async function (file) {
|
|
458
|
+
const { json } = this.opts();
|
|
459
|
+
try {
|
|
460
|
+
const raw = await readFile(file, "utf8");
|
|
461
|
+
const rec = RecordingSchema.parse(JSON.parse(raw));
|
|
462
|
+
const interaction = fitInteractionPolicy(rec);
|
|
463
|
+
const policy = { version: "1.0.0", interaction };
|
|
464
|
+
if (json) {
|
|
465
|
+
emitJson(program, ok(policy));
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
program.configureOutput().writeOut?.(`${JSON.stringify(policy, null, 2)}\n`);
|
|
469
|
+
process.exitCode = 0;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
emitJson(program, fail("E_INVALID_RECORDING", String(err)));
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
recording
|
|
477
|
+
.command("postdoc <take> [more...]")
|
|
478
|
+
.option("--decisions <file>", "path to a PostdocDecision[] JSON file (non-interactive mode)")
|
|
479
|
+
.option("--out <file>", "write the resulting Recording to this file instead of stdout")
|
|
480
|
+
.option("--json", "emit a JSON envelope")
|
|
481
|
+
.action(async function (take, more) {
|
|
482
|
+
const { decisions: decisionsFile, out, json } = this.opts();
|
|
483
|
+
try {
|
|
484
|
+
const files = [take, ...more];
|
|
485
|
+
const takes = await Promise.all(files.map(async (f) => {
|
|
486
|
+
const raw = await readFile(f, "utf8");
|
|
487
|
+
const parsed = AuthoringTakeSchema.parse(JSON.parse(raw));
|
|
488
|
+
return { recording: parsed.recording, values: new Map(Object.entries(parsed.values)) };
|
|
489
|
+
}));
|
|
490
|
+
const diff = diffTakes(takes);
|
|
491
|
+
let decisions;
|
|
492
|
+
if (decisionsFile !== undefined) {
|
|
493
|
+
decisions = await loadDecisions(decisionsFile);
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
decisions = await promptForDecisions(takes[0]);
|
|
497
|
+
}
|
|
498
|
+
const result = applyPostdoc(takes[0], diff, decisions);
|
|
499
|
+
if (out !== undefined) {
|
|
500
|
+
await writeFile(out, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
|
501
|
+
}
|
|
502
|
+
if (json) {
|
|
503
|
+
emitJson(program, ok(result));
|
|
504
|
+
}
|
|
505
|
+
else if (out === undefined) {
|
|
506
|
+
program.configureOutput().writeOut?.(`${JSON.stringify(result, null, 2)}\n`);
|
|
507
|
+
process.exitCode = 0;
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
process.exitCode = 0;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
if (err instanceof DecisionsParseError) {
|
|
515
|
+
emitJson(program, fail("E_INVALID_DECISIONS", String(err.cause)));
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
emitJson(program, fail("E_INVALID_TAKE", String(err)));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
const journey = program.command("journey");
|
|
523
|
+
/**
|
|
524
|
+
* `journey list` = ALL journeys' metadata via the store directly
|
|
525
|
+
* (promoted AND unpromoted) — a local/dev-facing listing of everything on
|
|
526
|
+
* disk. `journey find` (below) = promoted-only, via `JourneyRegistry.find`
|
|
527
|
+
* — the same promoted-only projection external callers (e.g. the
|
|
528
|
+
* mcp-facade) see. Keeping these distinct means `list` is useful for
|
|
529
|
+
* authoring/debugging while `find` genuinely reflects what's discoverable.
|
|
530
|
+
*/
|
|
531
|
+
journey
|
|
532
|
+
.command("list")
|
|
533
|
+
.option("--dir <path>", "journeys directory (default: ~/.jevitate/journeys)")
|
|
534
|
+
.option("--json", "emit a JSON envelope")
|
|
535
|
+
.action(async function () {
|
|
536
|
+
const { dir, json } = this.opts();
|
|
537
|
+
try {
|
|
538
|
+
const store = new FsJourneyStore(resolveJourneysDir(deps, dir));
|
|
539
|
+
const metas = await store.list();
|
|
540
|
+
const envelope = ok(metas);
|
|
541
|
+
if (json) {
|
|
542
|
+
emitJson(program, envelope);
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
const out = program.configureOutput().writeOut;
|
|
546
|
+
for (const m of metas) {
|
|
547
|
+
out?.(`${m.id}\t${m.name}${m.promoted ? "" : " (unpromoted)"}\n`);
|
|
548
|
+
}
|
|
549
|
+
process.exitCode = 0;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
emitJson(program, fail("E_JOURNEY_LIST", String(err)));
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
// RULING 5: uses `JourneyRegistry.find` (from `@jevitate/journey`) directly —
|
|
557
|
+
// NEVER `@jevitate/mcp-facade`'s `findCapabilities` — Slice 1 forbids the CLI
|
|
558
|
+
// depending on `@jevitate/mcp-facade`. `JourneyRegistry.find` is already
|
|
559
|
+
// promoted-only, so this is the same promoted-only view without the
|
|
560
|
+
// forbidden dependency.
|
|
561
|
+
journey
|
|
562
|
+
.command("find <query>")
|
|
563
|
+
.option("--dir <path>", "journeys directory (default: ~/.jevitate/journeys)")
|
|
564
|
+
.option("--json", "emit a JSON envelope")
|
|
565
|
+
.action(async function (query) {
|
|
566
|
+
const { dir, json } = this.opts();
|
|
567
|
+
try {
|
|
568
|
+
const store = new FsJourneyStore(resolveJourneysDir(deps, dir));
|
|
569
|
+
const registry = new JourneyRegistry(store);
|
|
570
|
+
const metas = await registry.find(query);
|
|
571
|
+
const capabilities = metas.map((m) => ({
|
|
572
|
+
id: m.id,
|
|
573
|
+
name: m.name,
|
|
574
|
+
description: m.description,
|
|
575
|
+
params: m.params,
|
|
576
|
+
}));
|
|
577
|
+
const envelope = ok(capabilities);
|
|
578
|
+
if (json) {
|
|
579
|
+
emitJson(program, envelope);
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
const out = program.configureOutput().writeOut;
|
|
583
|
+
for (const c of capabilities) {
|
|
584
|
+
out?.(`${c.id}\t${c.name}\tparams=[${c.params.join(", ")}]\n`);
|
|
585
|
+
}
|
|
586
|
+
process.exitCode = 0;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
emitJson(program, fail("E_JOURNEY_FIND", String(err)));
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
journey
|
|
594
|
+
.command("run <id>")
|
|
595
|
+
.option("--dir <path>", "journeys directory (default: ~/.jevitate/journeys)")
|
|
596
|
+
.option("--param <kv>", "param as key=value (repeatable)", collectParam, {})
|
|
597
|
+
// Ticket #7 (additive): opt a run into scoped self-healing. Default
|
|
598
|
+
// `fail-closed` preserves Slice 1 behavior exactly (no healer wired). A
|
|
599
|
+
// write/irreversible step NEVER auto-heals in any mode (enforced by the
|
|
600
|
+
// runtime's write floor). `hybrid`/`full` need an AI gateway, selected
|
|
601
|
+
// with --real/--fake-ai (mirrors `explore`); requesting a heal mode
|
|
602
|
+
// without one fails CLOSED, never a silent unhealed run.
|
|
603
|
+
.option("--self-heal <mode>", "self-heal policy mode: fail-closed | hybrid | full", "fail-closed")
|
|
604
|
+
.option("--real", "use live Jev + OpenRouter gateways for self-heal (requires keys)", false)
|
|
605
|
+
.option("--fake-ai", "use deterministic fake gateways for self-heal (pipeline smoke only)", false)
|
|
606
|
+
.option("--json", "emit a JSON envelope")
|
|
607
|
+
.action(async function (id) {
|
|
608
|
+
const { dir, param, selfHeal, real, fakeAi, json } = this.opts();
|
|
609
|
+
if (selfHeal !== "fail-closed" && selfHeal !== "hybrid" && selfHeal !== "full") {
|
|
610
|
+
emitJson(program, fail("E_SELF_HEAL_MODE", `--self-heal must be one of fail-closed | hybrid | full (got '${selfHeal}')`));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const selfHealMode = selfHeal;
|
|
614
|
+
// When a heal mode is requested, build the SelfHealer HERE (this action
|
|
615
|
+
// owns `deps` + the credential preflight); a missing/unselected gateway
|
|
616
|
+
// fails CLOSED before any browser launch, rather than silently running
|
|
617
|
+
// with no healer. fail-closed needs no gateway (identical to today).
|
|
618
|
+
let selfHealer;
|
|
619
|
+
let policy = safeRunPolicy();
|
|
620
|
+
if (selfHealMode !== "fail-closed") {
|
|
621
|
+
let judge;
|
|
622
|
+
let gen;
|
|
623
|
+
try {
|
|
624
|
+
({ judge, gen } = await buildExploreGateways(deps, { real: real ?? false, fakeAi: fakeAi ?? false }));
|
|
625
|
+
}
|
|
626
|
+
catch (err) {
|
|
627
|
+
if (err instanceof MissingCredentialError || err instanceof GatewaySelectionError) {
|
|
628
|
+
emitJson(program, fail("E_AI_SETUP_REQUIRED", err.message));
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
emitJson(program, fail("E_JOURNEY_RUN", String(err instanceof Error ? err.message : err)));
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
selfHealer = makeExploreSelfHealer(judge, gen);
|
|
636
|
+
policy = { ...policy, selfHeal: { mode: selfHealMode } };
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
// `runJourneyProgrammatically` validates params UP FRONT (before any
|
|
640
|
+
// browser launch). The default policy stays `safeRunPolicy()`
|
|
641
|
+
// (fail-closed secret mode) — only `selfHeal.mode` is threaded from
|
|
642
|
+
// the flag; a `--secret-mode` override is a later slice's concern.
|
|
643
|
+
const result = await runJourneyProgrammatically({
|
|
644
|
+
dir: resolveJourneysDir(deps, dir),
|
|
645
|
+
id,
|
|
646
|
+
params: param,
|
|
647
|
+
policy,
|
|
648
|
+
selfHealer,
|
|
649
|
+
});
|
|
650
|
+
const envelope = ok(result);
|
|
651
|
+
if (json) {
|
|
652
|
+
emitJson(program, envelope);
|
|
653
|
+
// "ok" and "healed" (a recovered run) are both successes; only
|
|
654
|
+
// "quarantined" is a non-zero exit.
|
|
655
|
+
if (result.outcome === "quarantined")
|
|
656
|
+
process.exitCode = 1;
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
program.configureOutput().writeOut?.(`${JSON.stringify(result)}\n`);
|
|
660
|
+
process.exitCode = result.outcome === "quarantined" ? 1 : 0;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
catch (err) {
|
|
664
|
+
if (err instanceof UnknownJourneyError) {
|
|
665
|
+
emitJson(program, fail("E_UNKNOWN_JOURNEY", String(err.message)));
|
|
666
|
+
}
|
|
667
|
+
else if (err instanceof ParamValidationError) {
|
|
668
|
+
emitJson(program, fail("E_INVALID_PARAMS", String(err.message)));
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
emitJson(program, fail("E_JOURNEY_RUN", String(err)));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
// #19 — publish a promoted local Journey to a registered distributed source.
|
|
676
|
+
// Preserves every publish-side guard in `@jevitate/sources` (promoted-only,
|
|
677
|
+
// secret-references-only, declared-origin coverage); writes onto a NEW
|
|
678
|
+
// `publish/<id>` branch and degrades gracefully when `gh` is absent.
|
|
679
|
+
journey
|
|
680
|
+
.command("publish <id>")
|
|
681
|
+
.requiredOption("--to <source>", "registered source name to publish into")
|
|
682
|
+
.option("--dir <path>", "journeys directory (default: ~/.jevitate/journeys)")
|
|
683
|
+
.option("--declare-origin <origin>", "origin this Journey is authorized for (repeatable; default: derived from navigate steps)", (v, prev) => [...prev, v], [])
|
|
684
|
+
.option("--as <id>", "publish under a different id than the local one")
|
|
685
|
+
.option("--json", "emit a JSON envelope")
|
|
686
|
+
.action(async function (id) {
|
|
687
|
+
const { to, dir, declareOrigin, as: asId, json } = this.opts();
|
|
688
|
+
try {
|
|
689
|
+
const apiDeps = resolveSourceApiDeps(deps);
|
|
690
|
+
const gh = deps.sources?.gh ?? realGhPort;
|
|
691
|
+
const result = await publishJourneyToSource({ ...apiDeps, gh }, {
|
|
692
|
+
journeysDir: resolveJourneysDir(deps, dir),
|
|
693
|
+
id,
|
|
694
|
+
toSource: to,
|
|
695
|
+
declareOrigins: declareOrigin,
|
|
696
|
+
asId,
|
|
697
|
+
});
|
|
698
|
+
const envelope = ok(result);
|
|
699
|
+
if (json) {
|
|
700
|
+
emitJson(program, envelope);
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
const out = program.configureOutput().writeOut;
|
|
704
|
+
out?.(`published '${id}' to '${to}' on branch ${result.branch}\n`);
|
|
705
|
+
if (result.prUrl)
|
|
706
|
+
out?.(`PR: ${result.prUrl}\n`);
|
|
707
|
+
else if (result.instructions)
|
|
708
|
+
out?.(`${result.instructions}\n`);
|
|
709
|
+
process.exitCode = 0;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
catch (err) {
|
|
713
|
+
if (err instanceof UnknownSourceError) {
|
|
714
|
+
emitJson(program, fail("E_JOURNEY_PUBLISH_UNKNOWN_SOURCE", err.message));
|
|
715
|
+
}
|
|
716
|
+
else if (err instanceof UnknownJourneyError) {
|
|
717
|
+
emitJson(program, fail("E_JOURNEY_PUBLISH_UNKNOWN_JOURNEY", err.message));
|
|
718
|
+
}
|
|
719
|
+
else if (err instanceof NotPromotedError) {
|
|
720
|
+
emitJson(program, fail("E_JOURNEY_PUBLISH_NOT_PROMOTED", err.message));
|
|
721
|
+
}
|
|
722
|
+
else if (err instanceof NoDeclaredOriginsError) {
|
|
723
|
+
emitJson(program, fail("E_JOURNEY_PUBLISH_NO_ORIGINS", err.message));
|
|
724
|
+
}
|
|
725
|
+
else if (err instanceof EmbeddedSecretError) {
|
|
726
|
+
emitJson(program, fail("E_JOURNEY_PUBLISH_SECRET", err.message));
|
|
727
|
+
}
|
|
728
|
+
else if (err instanceof UndeclaredOriginError) {
|
|
729
|
+
emitJson(program, fail("E_JOURNEY_PUBLISH_ORIGIN", err.message));
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
emitJson(program, fail("E_JOURNEY_PUBLISH", String(err instanceof Error ? err.message : err)));
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
// #18 — manage distributed Journey sources (add/list/pull/update/remove/
|
|
737
|
+
// trust). Trust is an explicit user act, content-hash-bound; add/pull/update
|
|
738
|
+
// never trust anything implicitly.
|
|
739
|
+
const source = program.command("source");
|
|
740
|
+
source
|
|
741
|
+
.command("add <name> <gitUrl>")
|
|
742
|
+
.option("--accept-tou", "acknowledge the source's declared Terms of Use (required before its Journeys can run)")
|
|
743
|
+
.option("--json", "emit a JSON envelope")
|
|
744
|
+
.action(async function (name, gitUrl) {
|
|
745
|
+
const { acceptTou, json } = this.opts();
|
|
746
|
+
try {
|
|
747
|
+
const apiDeps = resolveSourceApiDeps(deps);
|
|
748
|
+
const result = await addSource(apiDeps, {
|
|
749
|
+
name,
|
|
750
|
+
gitUrl,
|
|
751
|
+
acceptTou: acceptTou ?? false,
|
|
752
|
+
ackedBy: resolveApprovedBy(deps),
|
|
753
|
+
});
|
|
754
|
+
const envelope = ok(result);
|
|
755
|
+
if (json) {
|
|
756
|
+
emitJson(program, envelope);
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
const out = program.configureOutput().writeOut;
|
|
760
|
+
out?.(`added '${name}' pinned at ${result.pinnedCommit}\n`);
|
|
761
|
+
out?.(`Terms of Use for ${result.touSurface.gitUrl}:\n`);
|
|
762
|
+
for (const site of result.touSurface.sites)
|
|
763
|
+
out?.(` ${site.origin}\t${site.touBasis}\n`);
|
|
764
|
+
out?.(result.touAccepted ? "ToU acknowledged.\n" : "ToU NOT acknowledged — re-run with --accept-tou before running this source's Journeys.\n");
|
|
765
|
+
process.exitCode = 0;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
catch (err) {
|
|
769
|
+
if (err instanceof SourceValidationError) {
|
|
770
|
+
emitJson(program, fail("E_SOURCE_INVALID_MANIFEST", err.message));
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
emitJson(program, fail("E_SOURCE_ADD", String(err instanceof Error ? err.message : err)));
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
source
|
|
778
|
+
.command("list")
|
|
779
|
+
.option("--json", "emit a JSON envelope")
|
|
780
|
+
.action(async function () {
|
|
781
|
+
const { json } = this.opts();
|
|
782
|
+
try {
|
|
783
|
+
const listing = await listSources(resolveSourceApiDeps(deps));
|
|
784
|
+
const envelope = ok(listing);
|
|
785
|
+
if (json) {
|
|
786
|
+
emitJson(program, envelope);
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
const out = program.configureOutput().writeOut;
|
|
790
|
+
for (const s of listing) {
|
|
791
|
+
out?.(`${s.name}\t${s.gitUrl}\t${s.pinnedCommit}\ttrusted=[${s.trustedJourneys.join(", ")}]\n`);
|
|
792
|
+
}
|
|
793
|
+
process.exitCode = 0;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
catch (err) {
|
|
797
|
+
emitJson(program, fail("E_SOURCE_LIST", String(err instanceof Error ? err.message : err)));
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
source
|
|
801
|
+
.command("pull <name>")
|
|
802
|
+
.option("--json", "emit a JSON envelope")
|
|
803
|
+
.action(async function (name) {
|
|
804
|
+
const { json } = this.opts();
|
|
805
|
+
try {
|
|
806
|
+
const result = await pullSource(resolveSourceApiDeps(deps), name);
|
|
807
|
+
const envelope = ok(result);
|
|
808
|
+
if (json) {
|
|
809
|
+
emitJson(program, envelope);
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
program.configureOutput().writeOut?.(`pulled '${name}' (pin unchanged at ${result.pinnedCommit}; run 'source update' to advance)\n`);
|
|
813
|
+
process.exitCode = 0;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
catch (err) {
|
|
817
|
+
if (err instanceof UnknownSourceError) {
|
|
818
|
+
emitJson(program, fail("E_SOURCE_UNKNOWN", err.message));
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
emitJson(program, fail("E_SOURCE_PULL", String(err instanceof Error ? err.message : err)));
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
source
|
|
826
|
+
.command("update <name>")
|
|
827
|
+
.option("--json", "emit a JSON envelope")
|
|
828
|
+
.action(async function (name) {
|
|
829
|
+
const { json } = this.opts();
|
|
830
|
+
try {
|
|
831
|
+
const result = await updateSource(resolveSourceApiDeps(deps), name);
|
|
832
|
+
const envelope = ok(result);
|
|
833
|
+
if (json) {
|
|
834
|
+
emitJson(program, envelope);
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
program.configureOutput().writeOut?.(`updated '${name}' -> pinned at ${result.pinnedCommit}\n`);
|
|
838
|
+
process.exitCode = 0;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
catch (err) {
|
|
842
|
+
if (err instanceof UnknownSourceError) {
|
|
843
|
+
emitJson(program, fail("E_SOURCE_UNKNOWN", err.message));
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
emitJson(program, fail("E_SOURCE_UPDATE", String(err instanceof Error ? err.message : err)));
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
source
|
|
851
|
+
.command("remove <name>")
|
|
852
|
+
.option("--json", "emit a JSON envelope")
|
|
853
|
+
.action(async function (name) {
|
|
854
|
+
const { json } = this.opts();
|
|
855
|
+
try {
|
|
856
|
+
const result = await removeSource(resolveSourceApiDeps(deps), name);
|
|
857
|
+
const envelope = ok(result);
|
|
858
|
+
if (json) {
|
|
859
|
+
emitJson(program, envelope);
|
|
860
|
+
}
|
|
861
|
+
else {
|
|
862
|
+
program.configureOutput().writeOut?.(`removed '${name}'\n`);
|
|
863
|
+
process.exitCode = 0;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
catch (err) {
|
|
867
|
+
if (err instanceof UnknownSourceError) {
|
|
868
|
+
emitJson(program, fail("E_SOURCE_UNKNOWN", err.message));
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
emitJson(program, fail("E_SOURCE_REMOVE", String(err instanceof Error ? err.message : err)));
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
source
|
|
876
|
+
.command("trust <name> <journeyId>")
|
|
877
|
+
.description("explicitly trust one Journey in a source, bound to its current content hash")
|
|
878
|
+
.option("--json", "emit a JSON envelope")
|
|
879
|
+
.action(async function (name, journeyId) {
|
|
880
|
+
const { json } = this.opts();
|
|
881
|
+
try {
|
|
882
|
+
const result = await trustJourney(resolveSourceApiDeps(deps), {
|
|
883
|
+
sourceName: name,
|
|
884
|
+
journeyId,
|
|
885
|
+
approvedBy: resolveApprovedBy(deps),
|
|
886
|
+
});
|
|
887
|
+
// Never emit the Journey's content — only the address + bound hash.
|
|
888
|
+
const view = { sourceId: result.sourceId, journeyId: result.journeyId, contentHash: result.contentHash, approvedBy: result.approvedBy, approvedAtIso: result.approvedAtIso };
|
|
889
|
+
const envelope = ok(view);
|
|
890
|
+
if (json) {
|
|
891
|
+
emitJson(program, envelope);
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
program.configureOutput().writeOut?.(`trusted '${name}/${journeyId}' at ${result.contentHash}\n`);
|
|
895
|
+
process.exitCode = 0;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
catch (err) {
|
|
899
|
+
if (err instanceof UnknownSourceError) {
|
|
900
|
+
emitJson(program, fail("E_SOURCE_UNKNOWN", err.message));
|
|
901
|
+
}
|
|
902
|
+
else if (err instanceof UnknownJourneyError) {
|
|
903
|
+
emitJson(program, fail("E_SOURCE_UNKNOWN_JOURNEY", err.message));
|
|
904
|
+
}
|
|
905
|
+
else {
|
|
906
|
+
emitJson(program, fail("E_SOURCE_TRUST", String(err instanceof Error ? err.message : err)));
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
// #26 — run a Journey that lives in a trusted remote source, THROUGH the
|
|
911
|
+
// existing run-gate (`@jevitate/sources`' `resolveForRun`). RULING: this is a
|
|
912
|
+
// `source run` subcommand (not `journey run --from-source`) because the whole
|
|
913
|
+
// trust boundary is source-scoped — the `<source>/<id>` address, the
|
|
914
|
+
// per-source manifest/ToU-ack/trust records all live under `source`. `journey
|
|
915
|
+
// run` stays the LOCAL FsJourneyStore path; keeping remote runs here keeps the
|
|
916
|
+
// two trust boundaries visibly separate. The run NEVER bypasses a gate: every
|
|
917
|
+
// refusal below is a typed error thrown by `resolveForRun` BEFORE any browser.
|
|
918
|
+
source
|
|
919
|
+
.command("run <name> <journeyId>")
|
|
920
|
+
.description("run a Journey from a trusted remote source through the run-gate")
|
|
921
|
+
.option("--param <kv>", "param as key=value (repeatable)", collectParam, {})
|
|
922
|
+
.option("--json", "emit a JSON envelope")
|
|
923
|
+
.action(async function (name, journeyId) {
|
|
924
|
+
const { param, json } = this.opts();
|
|
925
|
+
try {
|
|
926
|
+
const apiDeps = {
|
|
927
|
+
...resolveSourceApiDeps(deps),
|
|
928
|
+
runJourney: deps.sources?.runJourney ?? realResolvedJourneyRunner,
|
|
929
|
+
};
|
|
930
|
+
const result = await runSourceJourney(apiDeps, { sourceName: name, journeyId, params: param });
|
|
931
|
+
const envelope = ok(result);
|
|
932
|
+
if (json) {
|
|
933
|
+
emitJson(program, envelope);
|
|
934
|
+
if (result.outcome === "quarantined")
|
|
935
|
+
process.exitCode = 1;
|
|
936
|
+
}
|
|
937
|
+
else {
|
|
938
|
+
program.configureOutput().writeOut?.(`${JSON.stringify(result)}\n`);
|
|
939
|
+
process.exitCode = result.outcome === "quarantined" ? 1 : 0;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
catch (err) {
|
|
943
|
+
// Each run-gate refusal maps to a distinct E_SOURCE_RUN* code so a
|
|
944
|
+
// caller can tell WHY the run was refused without string-matching.
|
|
945
|
+
if (err instanceof UnknownSourceError) {
|
|
946
|
+
emitJson(program, fail("E_SOURCE_RUN_UNKNOWN", err.message));
|
|
947
|
+
}
|
|
948
|
+
else if (err instanceof HashMismatchError) {
|
|
949
|
+
emitJson(program, fail("E_SOURCE_RUN_HASH_MISMATCH", err.message));
|
|
950
|
+
}
|
|
951
|
+
else if (err instanceof UntrustedRiskyJourneyError) {
|
|
952
|
+
emitJson(program, fail("E_SOURCE_RUN_UNTRUSTED", err.message));
|
|
953
|
+
}
|
|
954
|
+
else if (err instanceof UndeclaredOriginError) {
|
|
955
|
+
emitJson(program, fail("E_SOURCE_RUN_ORIGIN", err.message));
|
|
956
|
+
}
|
|
957
|
+
else if (err instanceof UndeclaredTouError) {
|
|
958
|
+
emitJson(program, fail("E_SOURCE_RUN_TOU", err.message));
|
|
959
|
+
}
|
|
960
|
+
else if (err instanceof EmbeddedSecretError) {
|
|
961
|
+
emitJson(program, fail("E_SOURCE_RUN_SECRET", err.message));
|
|
962
|
+
}
|
|
963
|
+
else if (err instanceof SourceValidationError) {
|
|
964
|
+
emitJson(program, fail("E_SOURCE_RUN_INVALID_MANIFEST", err.message));
|
|
965
|
+
}
|
|
966
|
+
else if (err instanceof ParamValidationError) {
|
|
967
|
+
emitJson(program, fail("E_INVALID_PARAMS", err.message));
|
|
968
|
+
}
|
|
969
|
+
else {
|
|
970
|
+
emitJson(program, fail("E_SOURCE_RUN", String(err instanceof Error ? err.message : err)));
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
const load = program.command("load");
|
|
975
|
+
load
|
|
976
|
+
.command("run <journeyId>")
|
|
977
|
+
.option("--dir <path>", "journeys directory (default: ~/.jevitate/journeys)")
|
|
978
|
+
.option("--param <kv>", "param as key=value (repeatable)", collectParam, {})
|
|
979
|
+
// `--authorized-origin` is mandatory, but enforced IN THE ACTION (below)
|
|
980
|
+
// via a `fail` envelope rather than commander's `.requiredOption` — which
|
|
981
|
+
// hard-exits via `process.exit`, inconsistent with this CLI's convention
|
|
982
|
+
// of emitting a JSON envelope + setting `process.exitCode` (see emitJson).
|
|
983
|
+
.option("--authorized-origin <origin>", "allowed load-test target origin (repeatable) — required, fails closed if omitted", (v, prev) => [...prev, v], [])
|
|
984
|
+
.option("--concurrency <n>", "pool size", "1")
|
|
985
|
+
.option("--iterations <n>", "iterations per actor", "1")
|
|
986
|
+
.option("--seed <n>", "master RNG seed", "1")
|
|
987
|
+
.option("--json", "emit a JSON envelope")
|
|
988
|
+
.action(async function (journeyId) {
|
|
989
|
+
const { dir, param, authorizedOrigin, concurrency, iterations, seed, json } = this.opts();
|
|
990
|
+
if (authorizedOrigin.length === 0) {
|
|
991
|
+
emitJson(program, fail("E_LOAD_RUN", "at least one --authorized-origin is required (refusing to load-test with an empty allowlist)"));
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
try {
|
|
995
|
+
const report = await runJourneyLoadTest({
|
|
996
|
+
dir: resolveJourneysDir(deps, dir),
|
|
997
|
+
id: journeyId,
|
|
998
|
+
params: param,
|
|
999
|
+
concurrency: Number(concurrency),
|
|
1000
|
+
iterationsPerActor: Number(iterations),
|
|
1001
|
+
seed: Number(seed),
|
|
1002
|
+
authorizedOrigins: authorizedOrigin,
|
|
1003
|
+
});
|
|
1004
|
+
const envelope = ok(report);
|
|
1005
|
+
if (json) {
|
|
1006
|
+
emitJson(program, envelope);
|
|
1007
|
+
}
|
|
1008
|
+
else {
|
|
1009
|
+
program.configureOutput().writeOut?.(`${JSON.stringify(report, null, 2)}\n`);
|
|
1010
|
+
process.exitCode = 0;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
catch (err) {
|
|
1014
|
+
if (err instanceof UnknownLoadJourneyError) {
|
|
1015
|
+
emitJson(program, fail("E_UNKNOWN_JOURNEY", String(err.message)));
|
|
1016
|
+
}
|
|
1017
|
+
else {
|
|
1018
|
+
emitJson(program, fail("E_LOAD_RUN", String(err instanceof Error ? err.message : err)));
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
1022
|
+
program
|
|
1023
|
+
.command("explore")
|
|
1024
|
+
.description("goal-directed exploration -> a deterministic Recording (authoring/test plane)")
|
|
1025
|
+
.option("--url <url>", "target URL (must be an authorized origin)")
|
|
1026
|
+
.option("--strategy <name>", "exploration strategy: goal (default) | coverage | exploratory | adversarial | usability (UX review: ranked, cited findings)", "goal")
|
|
1027
|
+
.option("--goal <text>", "natural-language goal / job (required for --strategy goal and usability)")
|
|
1028
|
+
.option("--app-class <class>", "app class for UX calibration (required for --strategy usability), e.g. consumer|admin|internal")
|
|
1029
|
+
.option("--success <spec>", "independent success assertion, e.g. urlIncludes:/inbox")
|
|
1030
|
+
.option("--feature <name>", "run the capability-scoped feature-testing mission (instead of --goal/--success)")
|
|
1031
|
+
.option("--route <glob>", "in-scope route glob for --feature (repeatable), e.g. /thread/**", (v, prev) => [...prev, v], [])
|
|
1032
|
+
.option("--allow <origin>", "authorized origin (repeatable); defaults to the URL's own origin", (v, prev) => [...prev, v], [])
|
|
1033
|
+
.option("--secret <value>", "a secret/PII value to keep out of every model call (repeatable)", (v, prev) => [...prev, v], [])
|
|
1034
|
+
.option("--max-actions <n>", "hard cap on executed actions")
|
|
1035
|
+
.option("--max-decisions <n>", "hard cap on model decisions")
|
|
1036
|
+
.option("--real", "use live Jev + OpenRouter gateways (requires keys)", false)
|
|
1037
|
+
.option("--fake-ai", "use deterministic fake gateways (pipeline smoke only)", false)
|
|
1038
|
+
.option("--out <dir>", "directory to write the emitted Recording")
|
|
1039
|
+
.option("--json", "emit a JSON envelope")
|
|
1040
|
+
.action(async function () {
|
|
1041
|
+
const o = this.opts();
|
|
1042
|
+
const strategy = o.strategy ?? "goal";
|
|
1043
|
+
// Additive coverage/exploratory strategy: proof-by-induction state coverage.
|
|
1044
|
+
// It takes no goal/success (the frontier itself is the objective), so it is
|
|
1045
|
+
// a distinct, goal-free path that leaves the goal strategy below unchanged.
|
|
1046
|
+
if (strategy === "coverage" || strategy === "exploratory") {
|
|
1047
|
+
if (!o.url) {
|
|
1048
|
+
emitJson(program, fail("E_EXPLORE_ARGS", "--url is required"));
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const covAllowlist = resolveExploreAllowlist(o.url, o.allow);
|
|
1052
|
+
const covBounds = {};
|
|
1053
|
+
if (o.maxActions !== undefined)
|
|
1054
|
+
covBounds.maxActions = Number(o.maxActions);
|
|
1055
|
+
if (o.maxDecisions !== undefined)
|
|
1056
|
+
covBounds.maxDecisions = Number(o.maxDecisions);
|
|
1057
|
+
let covJudge;
|
|
1058
|
+
let covGen;
|
|
1059
|
+
try {
|
|
1060
|
+
({ judge: covJudge, gen: covGen } = await buildExploreGateways(deps, {
|
|
1061
|
+
real: o.real ?? false,
|
|
1062
|
+
fakeAi: o.fakeAi ?? false,
|
|
1063
|
+
}));
|
|
1064
|
+
}
|
|
1065
|
+
catch (err) {
|
|
1066
|
+
if (err instanceof MissingCredentialError || err instanceof GatewaySelectionError) {
|
|
1067
|
+
emitJson(program, fail("E_AI_SETUP_REQUIRED", err.message));
|
|
1068
|
+
}
|
|
1069
|
+
else {
|
|
1070
|
+
emitJson(program, fail("E_EXPLORE_SETUP", String(err instanceof Error ? err.message : err)));
|
|
1071
|
+
}
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
try {
|
|
1075
|
+
const result = await runCoverageMission({
|
|
1076
|
+
url: o.url,
|
|
1077
|
+
allowlist: covAllowlist,
|
|
1078
|
+
judge: covJudge,
|
|
1079
|
+
gen: covGen,
|
|
1080
|
+
bounds: Object.keys(covBounds).length > 0 ? covBounds : undefined,
|
|
1081
|
+
outDir: o.out,
|
|
1082
|
+
browserPortFactory: deps.explore?.browserPortFactory,
|
|
1083
|
+
});
|
|
1084
|
+
const envelope = ok(result);
|
|
1085
|
+
if (o.json) {
|
|
1086
|
+
emitJson(program, envelope);
|
|
1087
|
+
}
|
|
1088
|
+
else {
|
|
1089
|
+
program.configureOutput().writeOut?.(`${JSON.stringify(result)}\n`);
|
|
1090
|
+
}
|
|
1091
|
+
if (result.coverage.defects.length > 0)
|
|
1092
|
+
process.exitCode = 1;
|
|
1093
|
+
}
|
|
1094
|
+
catch (err) {
|
|
1095
|
+
if (err instanceof UnauthorizedExploreTargetError) {
|
|
1096
|
+
emitJson(program, fail("E_UNAUTHORIZED_EXPLORE_TARGET", err.message));
|
|
1097
|
+
}
|
|
1098
|
+
else {
|
|
1099
|
+
emitJson(program, fail("E_EXPLORE_RUN", String(err instanceof Error ? err.message : err)));
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
// Additive adversarial strategy: a bounded "try to break it" run whose
|
|
1105
|
+
// stop decision comes from a trusted hard-signal oracle (never Jev's
|
|
1106
|
+
// Noul). Requires only --url; --goal/--success are goal-strategy inputs.
|
|
1107
|
+
if (strategy === "adversarial") {
|
|
1108
|
+
if (!o.url) {
|
|
1109
|
+
emitJson(program, fail("E_EXPLORE_ARGS", "--url is required for --strategy adversarial"));
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
const advAllowlist = resolveExploreAllowlist(o.url, o.allow);
|
|
1113
|
+
let advJudge;
|
|
1114
|
+
let advGen;
|
|
1115
|
+
try {
|
|
1116
|
+
({ judge: advJudge, gen: advGen } = await buildExploreGateways(deps, {
|
|
1117
|
+
real: o.real ?? false,
|
|
1118
|
+
fakeAi: o.fakeAi ?? false,
|
|
1119
|
+
}));
|
|
1120
|
+
}
|
|
1121
|
+
catch (err) {
|
|
1122
|
+
if (err instanceof MissingCredentialError || err instanceof GatewaySelectionError) {
|
|
1123
|
+
emitJson(program, fail("E_AI_SETUP_REQUIRED", err.message));
|
|
1124
|
+
}
|
|
1125
|
+
else {
|
|
1126
|
+
emitJson(program, fail("E_EXPLORE_SETUP", String(err instanceof Error ? err.message : err)));
|
|
1127
|
+
}
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const profileDir = await mkdtemp(join(tmpdir(), "jevitate-adversarial-"));
|
|
1131
|
+
try {
|
|
1132
|
+
const result = await runAdversarialCliMission({
|
|
1133
|
+
seedUrl: o.url,
|
|
1134
|
+
allowlist: advAllowlist,
|
|
1135
|
+
strategies: [
|
|
1136
|
+
"ordering-violation",
|
|
1137
|
+
"repeat-rapid",
|
|
1138
|
+
"boundary-input",
|
|
1139
|
+
"contradictory-actions",
|
|
1140
|
+
"nav-during-pending",
|
|
1141
|
+
],
|
|
1142
|
+
judgment: advJudge,
|
|
1143
|
+
generation: advGen,
|
|
1144
|
+
profileDir,
|
|
1145
|
+
browserPortFactory: deps.explore?.browserPortFactory,
|
|
1146
|
+
});
|
|
1147
|
+
emitJson(program, ok(result));
|
|
1148
|
+
// A discovered defect gates CI, mirroring how a failing test would.
|
|
1149
|
+
if (result.outcome === "defect")
|
|
1150
|
+
process.exitCode = 1;
|
|
1151
|
+
}
|
|
1152
|
+
catch (err) {
|
|
1153
|
+
if (err instanceof UnauthorizedExploreTargetError) {
|
|
1154
|
+
emitJson(program, fail("E_UNAUTHORIZED_EXPLORE_TARGET", err.message));
|
|
1155
|
+
}
|
|
1156
|
+
else {
|
|
1157
|
+
emitJson(program, fail("E_EXPLORE_RUN", String(err instanceof Error ? err.message : err)));
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
finally {
|
|
1161
|
+
await rm(profileDir, { recursive: true, force: true });
|
|
1162
|
+
}
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
// Additive: `--strategy usability` (issue #30) — a UX review. Reuses the
|
|
1166
|
+
// explore loop (goal = the job) and analyzes each observed screen against
|
|
1167
|
+
// the cited @jevitate/ux rubric. Findings are ADVISORY: a UX finding never
|
|
1168
|
+
// gates the run (no non-zero exit).
|
|
1169
|
+
if (strategy === "usability") {
|
|
1170
|
+
if (!o.url || !o.goal) {
|
|
1171
|
+
emitJson(program, fail("E_EXPLORE_ARGS", "--url and --goal (the job) are required for --strategy usability"));
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
if (!o.appClass) {
|
|
1175
|
+
emitJson(program, fail("E_UX_ARGS", "--app-class is required for --strategy usability"));
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
const uxAllowlist = resolveExploreAllowlist(o.url, o.allow);
|
|
1179
|
+
const uxBounds = {};
|
|
1180
|
+
if (o.maxActions !== undefined)
|
|
1181
|
+
uxBounds.maxActions = Number(o.maxActions);
|
|
1182
|
+
if (o.maxDecisions !== undefined)
|
|
1183
|
+
uxBounds.maxDecisions = Number(o.maxDecisions);
|
|
1184
|
+
let uxJudge;
|
|
1185
|
+
let uxGen;
|
|
1186
|
+
try {
|
|
1187
|
+
({ judge: uxJudge, gen: uxGen } = await buildExploreGateways(deps, {
|
|
1188
|
+
real: o.real ?? false,
|
|
1189
|
+
fakeAi: o.fakeAi ?? false,
|
|
1190
|
+
}));
|
|
1191
|
+
}
|
|
1192
|
+
catch (err) {
|
|
1193
|
+
if (err instanceof MissingCredentialError || err instanceof GatewaySelectionError) {
|
|
1194
|
+
emitJson(program, fail("E_AI_SETUP_REQUIRED", err.message));
|
|
1195
|
+
}
|
|
1196
|
+
else {
|
|
1197
|
+
emitJson(program, fail("E_EXPLORE_SETUP", String(err instanceof Error ? err.message : err)));
|
|
1198
|
+
}
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
try {
|
|
1202
|
+
const result = await runUsabilityMission({
|
|
1203
|
+
url: o.url,
|
|
1204
|
+
job: o.goal,
|
|
1205
|
+
allowlist: uxAllowlist,
|
|
1206
|
+
appContext: { appClass: o.appClass, job: o.goal },
|
|
1207
|
+
judge: uxJudge,
|
|
1208
|
+
gen: uxGen,
|
|
1209
|
+
bounds: Object.keys(uxBounds).length > 0 ? uxBounds : undefined,
|
|
1210
|
+
secrets: o.secret.length > 0 ? o.secret : undefined,
|
|
1211
|
+
outDir: o.out,
|
|
1212
|
+
browserPortFactory: deps.explore?.browserPortFactory,
|
|
1213
|
+
});
|
|
1214
|
+
emitJson(program, ok(result));
|
|
1215
|
+
}
|
|
1216
|
+
catch (err) {
|
|
1217
|
+
if (err instanceof UnauthorizedExploreTargetError) {
|
|
1218
|
+
emitJson(program, fail("E_UNAUTHORIZED_EXPLORE_TARGET", err.message));
|
|
1219
|
+
}
|
|
1220
|
+
else if (err instanceof UxAnalysisFailedError) {
|
|
1221
|
+
emitJson(program, fail("E_UX_ANALYSIS", err.message));
|
|
1222
|
+
}
|
|
1223
|
+
else {
|
|
1224
|
+
emitJson(program, fail("E_EXPLORE_RUN", String(err instanceof Error ? err.message : err)));
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
// Additive: `--feature <name>` runs the capability-scoped feature-testing
|
|
1230
|
+
// mission (ticket #2 / site #11). It is model-free, so it needs neither
|
|
1231
|
+
// --goal/--success nor a gateway selection; the goal-based path below is
|
|
1232
|
+
// untouched when --feature is absent.
|
|
1233
|
+
if (o.feature) {
|
|
1234
|
+
if (!o.url) {
|
|
1235
|
+
emitJson(program, fail("E_EXPLORE_ARGS", "--url is required with --feature"));
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
const featAllowlist = resolveExploreAllowlist(o.url, o.allow);
|
|
1239
|
+
const profileDir = await mkdtemp(join(tmpdir(), "jevitate-feature-"));
|
|
1240
|
+
try {
|
|
1241
|
+
const result = await runFeatureCliMission({
|
|
1242
|
+
seedUrl: o.url,
|
|
1243
|
+
allowlist: featAllowlist,
|
|
1244
|
+
capability: o.feature,
|
|
1245
|
+
routeGlobs: o.route ?? [],
|
|
1246
|
+
profileDir,
|
|
1247
|
+
});
|
|
1248
|
+
emitJson(program, ok(result));
|
|
1249
|
+
}
|
|
1250
|
+
catch (err) {
|
|
1251
|
+
if (err instanceof UnauthorizedExploreTargetError) {
|
|
1252
|
+
emitJson(program, fail("E_UNAUTHORIZED_EXPLORE_TARGET", err.message));
|
|
1253
|
+
}
|
|
1254
|
+
else {
|
|
1255
|
+
emitJson(program, fail("E_EXPLORE_RUN", String(err instanceof Error ? err.message : err)));
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
finally {
|
|
1259
|
+
await rm(profileDir, { recursive: true, force: true });
|
|
1260
|
+
}
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
if (!o.url || !o.goal || !o.success) {
|
|
1264
|
+
emitJson(program, fail("E_EXPLORE_ARGS", "--url, --goal and --success are all required"));
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
let successAssertion;
|
|
1268
|
+
try {
|
|
1269
|
+
successAssertion = parseAssertionSpec(o.success);
|
|
1270
|
+
}
|
|
1271
|
+
catch (err) {
|
|
1272
|
+
emitJson(program, fail("E_EXPLORE_ASSERTION", String(err instanceof Error ? err.message : err)));
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
const allowlist = resolveExploreAllowlist(o.url, o.allow);
|
|
1276
|
+
const bounds = {};
|
|
1277
|
+
if (o.maxActions !== undefined)
|
|
1278
|
+
bounds.maxActions = Number(o.maxActions);
|
|
1279
|
+
if (o.maxDecisions !== undefined)
|
|
1280
|
+
bounds.maxDecisions = Number(o.maxDecisions);
|
|
1281
|
+
let judge;
|
|
1282
|
+
let gen;
|
|
1283
|
+
try {
|
|
1284
|
+
({ judge, gen } = await buildExploreGateways(deps, { real: o.real ?? false, fakeAi: o.fakeAi ?? false }));
|
|
1285
|
+
}
|
|
1286
|
+
catch (err) {
|
|
1287
|
+
if (err instanceof MissingCredentialError || err instanceof GatewaySelectionError) {
|
|
1288
|
+
emitJson(program, fail("E_AI_SETUP_REQUIRED", err.message));
|
|
1289
|
+
}
|
|
1290
|
+
else {
|
|
1291
|
+
emitJson(program, fail("E_EXPLORE_SETUP", String(err instanceof Error ? err.message : err)));
|
|
1292
|
+
}
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
try {
|
|
1296
|
+
const result = await runExploration({
|
|
1297
|
+
url: o.url,
|
|
1298
|
+
goal: o.goal,
|
|
1299
|
+
successAssertion,
|
|
1300
|
+
allowlist,
|
|
1301
|
+
judge,
|
|
1302
|
+
gen,
|
|
1303
|
+
bounds: Object.keys(bounds).length > 0 ? bounds : undefined,
|
|
1304
|
+
secrets: o.secret.length > 0 ? o.secret : undefined,
|
|
1305
|
+
outDir: o.out,
|
|
1306
|
+
browserPortFactory: deps.explore?.browserPortFactory,
|
|
1307
|
+
});
|
|
1308
|
+
const envelope = ok(result);
|
|
1309
|
+
if (o.json) {
|
|
1310
|
+
emitJson(program, envelope);
|
|
1311
|
+
if (result.outcome !== "succeeded")
|
|
1312
|
+
process.exitCode = 1;
|
|
1313
|
+
}
|
|
1314
|
+
else {
|
|
1315
|
+
program.configureOutput().writeOut?.(`${JSON.stringify(result)}\n`);
|
|
1316
|
+
process.exitCode = result.outcome === "succeeded" ? 0 : 1;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
catch (err) {
|
|
1320
|
+
if (err instanceof UnauthorizedExploreTargetError) {
|
|
1321
|
+
emitJson(program, fail("E_UNAUTHORIZED_EXPLORE_TARGET", err.message));
|
|
1322
|
+
}
|
|
1323
|
+
else {
|
|
1324
|
+
emitJson(program, fail("E_EXPLORE_RUN", String(err instanceof Error ? err.message : err)));
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
});
|
|
1328
|
+
// Additive: `explore author-journey` — Jev-driving authors a promotable
|
|
1329
|
+
// Journey (Ticket #6). Drives the goal-based mission, feeds its take(s)
|
|
1330
|
+
// through RxD's diff/postdoc pipeline, and writes an UNPROMOTED,
|
|
1331
|
+
// parameterized Journey to the journeys store. The record-by-demonstration
|
|
1332
|
+
// authoring path is untouched.
|
|
1333
|
+
program
|
|
1334
|
+
.command("explore-author-journey")
|
|
1335
|
+
.description("Jev-driving authors a promotable Journey (authoring plane); never auto-promoted")
|
|
1336
|
+
.option("--url <url>", "target URL (must be an authorized origin)")
|
|
1337
|
+
.option("--goal <text>", "natural-language goal")
|
|
1338
|
+
.option("--success <spec>", "independent success assertion, e.g. urlIncludes:/confirmed")
|
|
1339
|
+
.option("--id <id>", "journey id (used for the <id>.json filename in the store)")
|
|
1340
|
+
.option("--name <name>", "human-readable journey name")
|
|
1341
|
+
.option("--takes <n>", "corroborating takes incl. discovery (default 1)", "1")
|
|
1342
|
+
.option("--journeys-dir <dir>", "journeys store directory (default: ~/.jevitate/journeys)")
|
|
1343
|
+
.option("--allow <origin>", "authorized origin (repeatable); defaults to the URL's own origin", (v, prev) => [...prev, v], [])
|
|
1344
|
+
.option("--max-actions <n>", "hard cap on executed actions")
|
|
1345
|
+
.option("--max-decisions <n>", "hard cap on model decisions")
|
|
1346
|
+
.option("--real", "use live Jev + OpenRouter gateways (requires keys)", false)
|
|
1347
|
+
.option("--fake-ai", "use deterministic fake gateways (pipeline smoke only)", false)
|
|
1348
|
+
.option("--json", "emit a JSON envelope")
|
|
1349
|
+
.action(async function () {
|
|
1350
|
+
const o = this.opts();
|
|
1351
|
+
if (!o.url || !o.goal || !o.success || !o.id || !o.name) {
|
|
1352
|
+
emitJson(program, fail("E_AUTHOR_ARGS", "--url, --goal, --success, --id and --name are all required"));
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
let successAssertion;
|
|
1356
|
+
try {
|
|
1357
|
+
successAssertion = parseAssertionSpec(o.success);
|
|
1358
|
+
}
|
|
1359
|
+
catch (err) {
|
|
1360
|
+
emitJson(program, fail("E_EXPLORE_ASSERTION", String(err instanceof Error ? err.message : err)));
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
const allowlist = resolveExploreAllowlist(o.url, o.allow);
|
|
1364
|
+
const bounds = {};
|
|
1365
|
+
if (o.maxActions !== undefined)
|
|
1366
|
+
bounds.maxActions = Number(o.maxActions);
|
|
1367
|
+
if (o.maxDecisions !== undefined)
|
|
1368
|
+
bounds.maxDecisions = Number(o.maxDecisions);
|
|
1369
|
+
let judge;
|
|
1370
|
+
let gen;
|
|
1371
|
+
try {
|
|
1372
|
+
({ judge, gen } = await buildExploreGateways(deps, { real: o.real ?? false, fakeAi: o.fakeAi ?? false }));
|
|
1373
|
+
}
|
|
1374
|
+
catch (err) {
|
|
1375
|
+
if (err instanceof MissingCredentialError || err instanceof GatewaySelectionError) {
|
|
1376
|
+
emitJson(program, fail("E_AI_SETUP_REQUIRED", err.message));
|
|
1377
|
+
}
|
|
1378
|
+
else {
|
|
1379
|
+
emitJson(program, fail("E_EXPLORE_SETUP", String(err instanceof Error ? err.message : err)));
|
|
1380
|
+
}
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
try {
|
|
1384
|
+
const result = await runAuthorJourney({
|
|
1385
|
+
url: o.url,
|
|
1386
|
+
goal: o.goal,
|
|
1387
|
+
successAssertion,
|
|
1388
|
+
allowlist,
|
|
1389
|
+
journeysDir: resolveJourneysDir(deps, o.journeysDir),
|
|
1390
|
+
journeyId: o.id,
|
|
1391
|
+
journeyName: o.name,
|
|
1392
|
+
takes: Number(o.takes),
|
|
1393
|
+
judge,
|
|
1394
|
+
gen,
|
|
1395
|
+
bounds: Object.keys(bounds).length > 0 ? bounds : undefined,
|
|
1396
|
+
browserPortFactory: deps.explore?.browserPortFactory,
|
|
1397
|
+
});
|
|
1398
|
+
const envelope = ok(result);
|
|
1399
|
+
if (o.json) {
|
|
1400
|
+
emitJson(program, envelope);
|
|
1401
|
+
if (result.outcome !== "authored")
|
|
1402
|
+
process.exitCode = 1;
|
|
1403
|
+
}
|
|
1404
|
+
else {
|
|
1405
|
+
program.configureOutput().writeOut?.(`${JSON.stringify(result)}\n`);
|
|
1406
|
+
process.exitCode = result.outcome === "authored" ? 0 : 1;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
catch (err) {
|
|
1410
|
+
if (err instanceof UnauthorizedExploreTargetError) {
|
|
1411
|
+
emitJson(program, fail("E_UNAUTHORIZED_EXPLORE_TARGET", err.message));
|
|
1412
|
+
}
|
|
1413
|
+
else {
|
|
1414
|
+
emitJson(program, fail("E_AUTHOR_JOURNEY", String(err instanceof Error ? err.message : err)));
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
});
|
|
1418
|
+
// Additive: `jevitate record` — record-by-demonstration (Ticket #22). Opens a
|
|
1419
|
+
// real browser on an authorized origin, lets the user demonstrate a flow, and
|
|
1420
|
+
// captures it into a schema-valid, replayable Recording written to disk. The
|
|
1421
|
+
// authorized-origin guard is enforced FIRST (fail-closed) inside runRecording,
|
|
1422
|
+
// before any browser is opened; the temp profile dir is always cleaned up.
|
|
1423
|
+
program
|
|
1424
|
+
.command("record")
|
|
1425
|
+
.description("record a demonstrated flow into a Recording (authoring plane)")
|
|
1426
|
+
.option("--url <url>", "start URL to demonstrate from (must be an authorized origin)")
|
|
1427
|
+
.option("--intent <text>", "your framing of the journey (carried to Recording.intent)")
|
|
1428
|
+
.option("--retro <text>", "optional retrospective note (carried to Recording.retro)")
|
|
1429
|
+
.option("--allow <origin>", "authorized origin (repeatable); defaults to the URL's own origin", (v, prev) => [...prev, v], [])
|
|
1430
|
+
.option("--headless", "run headless (default: headed — a record session is a live demonstration)", false)
|
|
1431
|
+
.option("--out <dir>", "directory to write the emitted Recording (default: ~/.jevitate/recordings)")
|
|
1432
|
+
.option("--json", "emit a JSON envelope")
|
|
1433
|
+
.action(async function () {
|
|
1434
|
+
const o = this.opts();
|
|
1435
|
+
if (!o.url) {
|
|
1436
|
+
emitJson(program, fail("E_RECORD_ARGS", "--url is required"));
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
const allowlist = resolveRecordAllowlist(o.url, o.allow);
|
|
1440
|
+
try {
|
|
1441
|
+
const result = await runRecording({
|
|
1442
|
+
url: o.url,
|
|
1443
|
+
allowlist,
|
|
1444
|
+
intent: o.intent,
|
|
1445
|
+
retro: o.retro,
|
|
1446
|
+
outDir: o.out,
|
|
1447
|
+
headless: o.headless ?? false,
|
|
1448
|
+
browserPortFactory: deps.record?.browserPortFactory,
|
|
1449
|
+
recorderFactory: deps.record?.recorderFactory,
|
|
1450
|
+
waitForStop: deps.record?.waitForStop,
|
|
1451
|
+
});
|
|
1452
|
+
const summary = {
|
|
1453
|
+
recordingPath: result.recordingPath,
|
|
1454
|
+
steps: result.steps,
|
|
1455
|
+
pages: result.pages,
|
|
1456
|
+
finalUrl: result.finalUrl,
|
|
1457
|
+
};
|
|
1458
|
+
if (o.json) {
|
|
1459
|
+
emitJson(program, ok(summary));
|
|
1460
|
+
}
|
|
1461
|
+
else {
|
|
1462
|
+
program.configureOutput().writeOut?.(`${JSON.stringify(summary)}\n`);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
catch (err) {
|
|
1466
|
+
if (err instanceof UnauthorizedExploreTargetError) {
|
|
1467
|
+
emitJson(program, fail("E_UNAUTHORIZED_EXPLORE_TARGET", err.message));
|
|
1468
|
+
}
|
|
1469
|
+
else {
|
|
1470
|
+
emitJson(program, fail("E_RECORD_RUN", String(err instanceof Error ? err.message : err)));
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
});
|
|
1474
|
+
// Additive: `@jevitate/regression` — reproduce -> minimize -> commit a
|
|
1475
|
+
// failing Recording into a committed regression artifact (Ticket #5).
|
|
1476
|
+
// Independent of the `journey`/`load` commands above; wires a real
|
|
1477
|
+
// Playwright-backed `makeActor` (one fresh browser session per
|
|
1478
|
+
// reproduce/minimize attempt, closed after each use) into
|
|
1479
|
+
// `runRegressionCapture`.
|
|
1480
|
+
const regression = program.command("regression");
|
|
1481
|
+
regression
|
|
1482
|
+
.command("capture")
|
|
1483
|
+
.requiredOption("--from <file>", "path to the schema-valid failing Recording JSON to capture")
|
|
1484
|
+
.requiredOption("--id <id>", "regression id (used for the committed <id>.recording.json/<id>.meta.json filenames)")
|
|
1485
|
+
.option("--dir <path>", "regressions directory (default: ~/.jevitate/regressions)")
|
|
1486
|
+
.option("--attempts <n>", "reproduction attempts before labeling flaky", "3")
|
|
1487
|
+
.option("--summary <text>", "optional human-readable bug summary recorded in the meta sidecar")
|
|
1488
|
+
.option("--json", "emit a JSON envelope")
|
|
1489
|
+
.action(async function () {
|
|
1490
|
+
const { from, id, dir, attempts, summary, json } = this.opts();
|
|
1491
|
+
const opened = [];
|
|
1492
|
+
try {
|
|
1493
|
+
const raw = JSON.parse(await readFile(from, "utf8"));
|
|
1494
|
+
const recording = RecordingSchema.parse(raw);
|
|
1495
|
+
const result = await runRegressionCapture({
|
|
1496
|
+
failingRecordingPath: from,
|
|
1497
|
+
id,
|
|
1498
|
+
regressionsDir: resolveRegressionsDir(dir),
|
|
1499
|
+
attempts: Number(attempts),
|
|
1500
|
+
bugSummary: summary,
|
|
1501
|
+
makeActor: async () => {
|
|
1502
|
+
const { actor, close } = await makeRealBrowserActor(recording.site);
|
|
1503
|
+
opened.push(close);
|
|
1504
|
+
return actor;
|
|
1505
|
+
},
|
|
1506
|
+
});
|
|
1507
|
+
const envelope = ok(result);
|
|
1508
|
+
if (json) {
|
|
1509
|
+
emitJson(program, envelope);
|
|
1510
|
+
}
|
|
1511
|
+
else {
|
|
1512
|
+
program.configureOutput().writeOut?.(`${JSON.stringify(result)}\n`);
|
|
1513
|
+
process.exitCode = 0;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
catch (err) {
|
|
1517
|
+
emitJson(program, fail("E_REGRESSION_CAPTURE", String(err instanceof Error ? err.message : err)));
|
|
1518
|
+
}
|
|
1519
|
+
finally {
|
|
1520
|
+
for (const close of opened)
|
|
1521
|
+
await close();
|
|
1522
|
+
}
|
|
1523
|
+
});
|
|
1524
|
+
// Additive: `mission target` — register/list/promote exploration mission
|
|
1525
|
+
// targets (Ticket #21). Wires the real fs-backed `@jevitate/missions`
|
|
1526
|
+
// store/registry (the SAME store `queue_exploration` resolves promoted
|
|
1527
|
+
// targets from). SECURITY: `add` registers UNPROMOTED — the promoted-only
|
|
1528
|
+
// gate stays intact, so a registered target is not resolvable by
|
|
1529
|
+
// `queue_exploration` until a separate `promote` flips it.
|
|
1530
|
+
const mission = program.command("mission");
|
|
1531
|
+
const missionTarget = mission.command("target");
|
|
1532
|
+
missionTarget
|
|
1533
|
+
.command("add <id>")
|
|
1534
|
+
.description("register an exploration mission target (UNPROMOTED — not usable by queue_exploration until promoted)")
|
|
1535
|
+
.option("--name <name>", "human-readable target name")
|
|
1536
|
+
.option("--authorized-origin <origin>", "the single authorized exploration origin for this target")
|
|
1537
|
+
.option("--base-url <url>", "the base URL a mission starts navigation from")
|
|
1538
|
+
.option("--description <text>", "optional human-readable description")
|
|
1539
|
+
.option("--dir <path>", "mission targets directory (default: ~/.jevitate/missions/targets)")
|
|
1540
|
+
.option("--json", "emit a JSON envelope")
|
|
1541
|
+
.action(async function (id) {
|
|
1542
|
+
const { name, authorizedOrigin, baseUrl, description, dir, json } = this.opts();
|
|
1543
|
+
// Validate in-action + fail envelope (not commander's hard-exiting
|
|
1544
|
+
// `.requiredOption`), matching this CLI's convention.
|
|
1545
|
+
if (!name || !authorizedOrigin || !baseUrl) {
|
|
1546
|
+
emitJson(program, fail("E_MISSION_TARGET_ARGS", "--name, --authorized-origin and --base-url are all required"));
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
try {
|
|
1550
|
+
const ctx = missionTargetContext(resolveMissionTargetsDir(deps, dir));
|
|
1551
|
+
const target = await addMissionTarget(ctx, { id, name, authorizedOrigin, baseUrl, description });
|
|
1552
|
+
const envelope = ok(target);
|
|
1553
|
+
if (json) {
|
|
1554
|
+
emitJson(program, envelope);
|
|
1555
|
+
}
|
|
1556
|
+
else {
|
|
1557
|
+
program.configureOutput().writeOut?.(`registered mission target '${target.id}' (unpromoted — run 'jevitate mission target promote ${target.id}' to make it resolvable)\n`);
|
|
1558
|
+
process.exitCode = 0;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
catch (err) {
|
|
1562
|
+
emitJson(program, fail("E_MISSION_TARGET_ADD", String(err instanceof Error ? err.message : err)));
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
missionTarget
|
|
1566
|
+
.command("list")
|
|
1567
|
+
.description("list ALL mission targets (promoted and unpromoted) — a local/dev-facing listing")
|
|
1568
|
+
.option("--dir <path>", "mission targets directory (default: ~/.jevitate/missions/targets)")
|
|
1569
|
+
.option("--json", "emit a JSON envelope")
|
|
1570
|
+
.action(async function () {
|
|
1571
|
+
const { dir, json } = this.opts();
|
|
1572
|
+
try {
|
|
1573
|
+
const ctx = missionTargetContext(resolveMissionTargetsDir(deps, dir));
|
|
1574
|
+
const targets = await listMissionTargets(ctx);
|
|
1575
|
+
const envelope = ok(targets);
|
|
1576
|
+
if (json) {
|
|
1577
|
+
emitJson(program, envelope);
|
|
1578
|
+
}
|
|
1579
|
+
else {
|
|
1580
|
+
const out = program.configureOutput().writeOut;
|
|
1581
|
+
for (const t of targets) {
|
|
1582
|
+
out?.(`${t.id}\t${t.name}\t${t.authorizedOrigin}${t.promoted ? "" : " (unpromoted)"}\n`);
|
|
1583
|
+
}
|
|
1584
|
+
process.exitCode = 0;
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
catch (err) {
|
|
1588
|
+
emitJson(program, fail("E_MISSION_TARGET_LIST", String(err instanceof Error ? err.message : err)));
|
|
1589
|
+
}
|
|
1590
|
+
});
|
|
1591
|
+
missionTarget
|
|
1592
|
+
.command("promote <id>")
|
|
1593
|
+
.description("promote a registered target so queue_exploration can resolve it")
|
|
1594
|
+
.option("--dir <path>", "mission targets directory (default: ~/.jevitate/missions/targets)")
|
|
1595
|
+
.option("--json", "emit a JSON envelope")
|
|
1596
|
+
.action(async function (id) {
|
|
1597
|
+
const { dir, json } = this.opts();
|
|
1598
|
+
try {
|
|
1599
|
+
const ctx = missionTargetContext(resolveMissionTargetsDir(deps, dir));
|
|
1600
|
+
const target = await promoteMissionTarget(ctx, id);
|
|
1601
|
+
const envelope = ok(target);
|
|
1602
|
+
if (json) {
|
|
1603
|
+
emitJson(program, envelope);
|
|
1604
|
+
}
|
|
1605
|
+
else {
|
|
1606
|
+
program.configureOutput().writeOut?.(`promoted mission target '${target.id}'\n`);
|
|
1607
|
+
process.exitCode = 0;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
catch (err) {
|
|
1611
|
+
if (err instanceof UnknownMissionTargetError) {
|
|
1612
|
+
emitJson(program, fail("E_UNKNOWN_MISSION_TARGET", err.message));
|
|
1613
|
+
}
|
|
1614
|
+
else {
|
|
1615
|
+
emitJson(program, fail("E_MISSION_TARGET_PROMOTE", String(err instanceof Error ? err.message : err)));
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
});
|
|
1619
|
+
// Additive: `jevitate mcp` (Ticket #20) — start an MCP stdio server that
|
|
1620
|
+
// exposes ONLY `@jevitate/mcp-facade`'s allowlisted tools (never the raw
|
|
1621
|
+
// browser primitives in FORBIDDEN_TOOLS). This is the subcommand form of the
|
|
1622
|
+
// MCP server (single-bundle deployment — no separate published package).
|
|
1623
|
+
// The server owns stdin/stdout as the MCP protocol channel, so on success it
|
|
1624
|
+
// blocks and writes NOTHING to stdout; only a setup failure (before the
|
|
1625
|
+
// transport connects) emits a JSON envelope.
|
|
1626
|
+
program
|
|
1627
|
+
.command("mcp")
|
|
1628
|
+
.description("start an MCP stdio server exposing only the allowlisted Jevitate tools")
|
|
1629
|
+
.option("--dir <path>", "journeys directory (default: ~/.jevitate/journeys)")
|
|
1630
|
+
.option("--print-config <harness>", "print the config snippet to register `jevitate mcp` in a harness (claude | cursor | codex | json) and exit — prints only, writes nothing")
|
|
1631
|
+
.action(async function () {
|
|
1632
|
+
const { dir, printConfig } = this.opts();
|
|
1633
|
+
// `--print-config <harness>` is the universal escape hatch: render the
|
|
1634
|
+
// exact registration snippet and exit WITHOUT starting the server (safe:
|
|
1635
|
+
// no writes, no stdio takeover). An unknown harness is a fail envelope.
|
|
1636
|
+
if (printConfig !== undefined) {
|
|
1637
|
+
const harness = printConfig;
|
|
1638
|
+
if (harness !== "claude" && harness !== "cursor" && harness !== "codex" && harness !== "json") {
|
|
1639
|
+
emitJson(program, fail("E_MCP_PRINT_CONFIG", `--print-config must be one of claude | cursor | codex | json (got '${printConfig}')`));
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
program.configureOutput().writeOut?.(`${renderPrintConfig(harness)}\n`);
|
|
1643
|
+
process.exitCode = 0;
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
try {
|
|
1647
|
+
// Credential store + generation gateway for the allowlisted
|
|
1648
|
+
// `ai_generate_text` tool. The gateway is the REAL OpenRouter adapter:
|
|
1649
|
+
// the key is read only inside it (Authorization header only), every
|
|
1650
|
+
// outbound payload passes the never-to-model guard, and the facade's
|
|
1651
|
+
// preflight returns a typed `setup_required` when the key is absent —
|
|
1652
|
+
// so no `--real/--fake` flag is needed for the non-interactive server.
|
|
1653
|
+
const aiStore = envCredentialStore(deps.ai?.env ?? process.env, deps.ai?.localConfig ?? {});
|
|
1654
|
+
const generationGateway = deps.ai?.gateway ??
|
|
1655
|
+
new OpenRouterGenerationGateway({
|
|
1656
|
+
store: aiStore,
|
|
1657
|
+
catalog: deps.ai?.catalog ?? DEFAULT_EXPLORE_CATALOG,
|
|
1658
|
+
constraints: deps.ai?.constraints ?? DEFAULT_EXPLORE_CONSTRAINTS,
|
|
1659
|
+
call: await realOpenRouterCall(),
|
|
1660
|
+
});
|
|
1661
|
+
await startMcpServer({
|
|
1662
|
+
journeysDir: resolveJourneysDir(deps, dir),
|
|
1663
|
+
missionTargetsDir: resolveMissionTargetsDir(deps),
|
|
1664
|
+
missionQueueDir: resolveDataDir(["missions", "queue"]),
|
|
1665
|
+
inboxDir: resolveInboxDir(deps),
|
|
1666
|
+
credentialStore: aiStore,
|
|
1667
|
+
generationGateway,
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
catch (err) {
|
|
1671
|
+
emitJson(program, fail("E_MCP_SERVE", String(err instanceof Error ? err.message : err)));
|
|
1672
|
+
}
|
|
1673
|
+
});
|
|
1674
|
+
// Additive: `jevitate ui` (Task 8) — starts the local, loopback-only HTTP
|
|
1675
|
+
// HITL approval dashboard (ui-api.ts's `startUiServer`). Resolves the SAME
|
|
1676
|
+
// inbox dir `jevitate mcp`'s inbox tools serve (resolveInboxDir), so the
|
|
1677
|
+
// two commands agree on where approvals/handbacks/reviews live. On success
|
|
1678
|
+
// it prints the bound URL (carrying the capability token) and stays alive —
|
|
1679
|
+
// the open HTTP server keeps the process running, the same way `mcp`'s open
|
|
1680
|
+
// stdio transport does.
|
|
1681
|
+
program
|
|
1682
|
+
.command("ui")
|
|
1683
|
+
.description("start the local HITL approval dashboard (loopback-only HTTP server)")
|
|
1684
|
+
.option("--port <n>", "explicit port (fails on conflict; default 4180, retries on conflict)")
|
|
1685
|
+
.option("--no-open", "do not open the dashboard URL in the default browser")
|
|
1686
|
+
.option("--inbox-dir <path>", "inbox store directory (default: ~/.jevitate/inbox — same dir `jevitate mcp` serves)")
|
|
1687
|
+
.action(async function () {
|
|
1688
|
+
const o = this.opts();
|
|
1689
|
+
try {
|
|
1690
|
+
const start = deps.ui?.startUiServer ?? startUiServer;
|
|
1691
|
+
const handle = await start({
|
|
1692
|
+
inboxDir: resolveInboxDir(deps, o.inboxDir),
|
|
1693
|
+
open: o.open ?? true,
|
|
1694
|
+
...(o.port !== undefined ? { port: Number(o.port) } : {}),
|
|
1695
|
+
});
|
|
1696
|
+
program.configureOutput().writeOut?.(`${handle.url}\n`);
|
|
1697
|
+
}
|
|
1698
|
+
catch (err) {
|
|
1699
|
+
emitJson(program, fail("E_UI_SERVE", String(err instanceof Error ? err.message : err)));
|
|
1700
|
+
}
|
|
1701
|
+
});
|
|
1702
|
+
// Additive: `jevitate ux <recording>` (issue #30) — offline UX review of a
|
|
1703
|
+
// saved Recording. Findings are advisory; a `failed` analysis is a non-zero
|
|
1704
|
+
// fail envelope (never a fabricated clean report).
|
|
1705
|
+
program
|
|
1706
|
+
.command("ux <recording>")
|
|
1707
|
+
.description("offline UX review of a saved Recording — ranked, cited usability findings")
|
|
1708
|
+
.option("--app-class <class>", "app class for calibration (required), e.g. consumer|admin|internal")
|
|
1709
|
+
.option("--persona <p>", "optional persona for calibration")
|
|
1710
|
+
.option("--job <text>", "the job the flow pursues (improves relevance)")
|
|
1711
|
+
.option("--out <dir>", "directory to write the UX report")
|
|
1712
|
+
.option("--real", "use live Jev gateways (requires keys)", false)
|
|
1713
|
+
.option("--fake-ai", "use deterministic fake gateways", false)
|
|
1714
|
+
.option("--json", "emit a JSON envelope")
|
|
1715
|
+
.action(async function (recordingPath) {
|
|
1716
|
+
const o = this.opts();
|
|
1717
|
+
if (!o.appClass) {
|
|
1718
|
+
emitJson(program, fail("E_UX_ARGS", "--app-class is required"));
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
let recording;
|
|
1722
|
+
try {
|
|
1723
|
+
recording = RecordingSchema.parse(JSON.parse(await readFile(recordingPath, "utf8")));
|
|
1724
|
+
}
|
|
1725
|
+
catch (err) {
|
|
1726
|
+
emitJson(program, fail("E_UX_RECORDING", String(err instanceof Error ? err.message : err)));
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
let uxJudge;
|
|
1730
|
+
try {
|
|
1731
|
+
({ judge: uxJudge } = await buildExploreGateways(deps, { real: o.real ?? false, fakeAi: o.fakeAi ?? false }));
|
|
1732
|
+
}
|
|
1733
|
+
catch (err) {
|
|
1734
|
+
if (err instanceof MissingCredentialError || err instanceof GatewaySelectionError) {
|
|
1735
|
+
emitJson(program, fail("E_AI_SETUP_REQUIRED", err.message));
|
|
1736
|
+
}
|
|
1737
|
+
else {
|
|
1738
|
+
emitJson(program, fail("E_UX_SETUP", String(err instanceof Error ? err.message : err)));
|
|
1739
|
+
}
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
try {
|
|
1743
|
+
const result = await runUxReview({
|
|
1744
|
+
recording,
|
|
1745
|
+
appContext: {
|
|
1746
|
+
appClass: o.appClass,
|
|
1747
|
+
...(o.persona ? { persona: o.persona } : {}),
|
|
1748
|
+
...(o.job ? { job: o.job } : {}),
|
|
1749
|
+
},
|
|
1750
|
+
judge: uxJudge,
|
|
1751
|
+
outDir: o.out,
|
|
1752
|
+
});
|
|
1753
|
+
emitJson(program, ok(result));
|
|
1754
|
+
}
|
|
1755
|
+
catch (err) {
|
|
1756
|
+
if (err instanceof UxAnalysisFailedError) {
|
|
1757
|
+
emitJson(program, fail("E_UX_ANALYSIS", err.message));
|
|
1758
|
+
}
|
|
1759
|
+
else {
|
|
1760
|
+
emitJson(program, fail("E_UX_RUN", String(err instanceof Error ? err.message : err)));
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
});
|
|
1764
|
+
registerAiCommands(program, deps);
|
|
1765
|
+
return program;
|
|
1766
|
+
}
|
|
1767
|
+
/** Distinct from MissingCredentialError: "no --real/--fake-ai selected" vs "keys missing." */
|
|
1768
|
+
class GatewaySelectionError extends Error {
|
|
1769
|
+
}
|
|
1770
|
+
const DEFAULT_EXPLORE_CATALOG = [
|
|
1771
|
+
{ id: "openai/gpt-4o-mini", promptUsdPer1k: 0.15, completionUsdPer1k: 0.6, regions: [], latencyClass: "fast", capabilities: [] },
|
|
1772
|
+
];
|
|
1773
|
+
const DEFAULT_EXPLORE_CONSTRAINTS = { requiredCapabilities: [] };
|
|
1774
|
+
/**
|
|
1775
|
+
* Selects the exploration gateways. Injected gateways (tests) win; otherwise
|
|
1776
|
+
* `--real` builds the live Jev + OpenRouter adapters behind a fail-closed
|
|
1777
|
+
* credential preflight, and `--fake-ai` uses deterministic fakes (a pipeline
|
|
1778
|
+
* smoke — the fake judge always proposes `done`, so it will not drive to a
|
|
1779
|
+
* goal). No selection is a fail-closed refusal, never a silent fake.
|
|
1780
|
+
*/
|
|
1781
|
+
async function buildExploreGateways(deps, opts) {
|
|
1782
|
+
if (deps.explore?.judge && deps.explore?.gen) {
|
|
1783
|
+
return { judge: deps.explore.judge, gen: deps.explore.gen };
|
|
1784
|
+
}
|
|
1785
|
+
const store = envCredentialStore(deps.explore?.env ?? process.env, deps.explore?.localConfig ?? {});
|
|
1786
|
+
if (opts.real) {
|
|
1787
|
+
requireKeys("generation", store); // fail-closed
|
|
1788
|
+
requireKeys("judgment", store); // fail-closed
|
|
1789
|
+
const gen = new OpenRouterGenerationGateway({
|
|
1790
|
+
store,
|
|
1791
|
+
catalog: DEFAULT_EXPLORE_CATALOG,
|
|
1792
|
+
constraints: DEFAULT_EXPLORE_CONSTRAINTS,
|
|
1793
|
+
call: await realOpenRouterCall(),
|
|
1794
|
+
});
|
|
1795
|
+
const judge = new JevJudgmentGateway(store, await realJevClientCall());
|
|
1796
|
+
return { judge, gen };
|
|
1797
|
+
}
|
|
1798
|
+
if (opts.fakeAi) {
|
|
1799
|
+
return { judge: fakeDoneJudge(), gen: new FakeGenerationGateway() };
|
|
1800
|
+
}
|
|
1801
|
+
throw new GatewaySelectionError("no gateway selected — pass --real for live Jev+OpenRouter (after `jevitate ai setup`), or --fake-ai for a deterministic pipeline smoke");
|
|
1802
|
+
}
|
|
1803
|
+
/** A judge that always proposes `done` — used only by `--fake-ai` (smoke). */
|
|
1804
|
+
function fakeDoneJudge() {
|
|
1805
|
+
return {
|
|
1806
|
+
async systemOne(_args) {
|
|
1807
|
+
return { op: { kind: "choice", value: "done", confidence: 1 } };
|
|
1808
|
+
},
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
/** Real OpenRouter seam (lazy import; not unit-tested) — mirrors ai-cli.ts. */
|
|
1812
|
+
async function realOpenRouterCall() {
|
|
1813
|
+
const { generateObject } = await import("ai");
|
|
1814
|
+
const { createOpenRouter } = await import("@openrouter/ai-sdk-provider");
|
|
1815
|
+
return async ({ model, schema, body, authHeader }) => {
|
|
1816
|
+
const openrouter = createOpenRouter({ headers: { Authorization: authHeader } });
|
|
1817
|
+
const start = Date.now();
|
|
1818
|
+
const { object } = await generateObject({ model: openrouter(model), schema, prompt: JSON.stringify(body) });
|
|
1819
|
+
return { object, latencyMs: Date.now() - start };
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
/**
|
|
1823
|
+
* Real Jev seam (documented, lazy, not unit-tested). Uses a non-literal
|
|
1824
|
+
* specifier so this package builds and tests without `@typesafe-ai/sdk`
|
|
1825
|
+
* installed; the host installs + wires it for live judgment.
|
|
1826
|
+
*/
|
|
1827
|
+
async function realJevClientCall() {
|
|
1828
|
+
const specifier = "@typesafe-ai/sdk";
|
|
1829
|
+
const sdk = (await import(specifier).catch(() => {
|
|
1830
|
+
throw new Error("live judgment requires @typesafe-ai/sdk to be installed and wired (see jev.ts seam)");
|
|
1831
|
+
}));
|
|
1832
|
+
return async ({ state, questions, authHeader }) => {
|
|
1833
|
+
const client = sdk.createClient({ authHeader });
|
|
1834
|
+
return client.systemOne({ state, questions });
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* Distinguishes a malformed `--decisions <file>` (E_INVALID_DECISIONS) from
|
|
1839
|
+
* every other failure mode of the `postdoc` action (E_INVALID_TAKE) without
|
|
1840
|
+
* making `loadDecisions` itself responsible for emitting the CLI envelope —
|
|
1841
|
+
* matching this file's existing pattern of one try/catch per subcommand
|
|
1842
|
+
* mapping to one error code.
|
|
1843
|
+
*/
|
|
1844
|
+
class DecisionsParseError extends Error {
|
|
1845
|
+
cause;
|
|
1846
|
+
constructor(cause) {
|
|
1847
|
+
super(String(cause));
|
|
1848
|
+
this.cause = cause;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
async function loadDecisions(file) {
|
|
1852
|
+
let raw;
|
|
1853
|
+
let parsed;
|
|
1854
|
+
try {
|
|
1855
|
+
raw = await readFile(file, "utf8");
|
|
1856
|
+
parsed = JSON.parse(raw);
|
|
1857
|
+
}
|
|
1858
|
+
catch (err) {
|
|
1859
|
+
throw new DecisionsParseError(err);
|
|
1860
|
+
}
|
|
1861
|
+
const result = PostdocDecisionsSchema.safeParse(parsed);
|
|
1862
|
+
if (!result.success) {
|
|
1863
|
+
throw new DecisionsParseError(result.error);
|
|
1864
|
+
}
|
|
1865
|
+
return result.data;
|
|
1866
|
+
}
|
|
1867
|
+
/**
|
|
1868
|
+
* Thin `@clack/prompts` adapter: walks `authoring`'s fill/select steps in
|
|
1869
|
+
* order and asks the human how to classify each one. ALL logic (variance
|
|
1870
|
+
* guards, secret-materialization checks, the actual step rewrite) lives in
|
|
1871
|
+
* `applyPostdoc`/`diffTakes` — this function only collects a
|
|
1872
|
+
* `PostdocDecision[]` to hand them.
|
|
1873
|
+
*/
|
|
1874
|
+
async function promptForDecisions(authoring) {
|
|
1875
|
+
clack.intro("recording postdoc — review captured fill/select steps");
|
|
1876
|
+
const decisions = [];
|
|
1877
|
+
const fillSteps = flattenBaseFillSteps(authoring.recording);
|
|
1878
|
+
for (const { ref } of fillSteps) {
|
|
1879
|
+
const classify = await clack.select({
|
|
1880
|
+
message: `Step ${ref.page}:${ref.step} — how should this value be classified?`,
|
|
1881
|
+
options: [
|
|
1882
|
+
{ value: "constant", label: "constant", hint: "fix this value in the artifact" },
|
|
1883
|
+
{ value: "variable", label: "variable", hint: "prompt for a value at replay time" },
|
|
1884
|
+
{ value: "handback", label: "handback", hint: "hand control to a human at replay time" },
|
|
1885
|
+
],
|
|
1886
|
+
});
|
|
1887
|
+
if (clack.isCancel(classify)) {
|
|
1888
|
+
clack.cancel("postdoc review cancelled");
|
|
1889
|
+
process.exit(1);
|
|
1890
|
+
}
|
|
1891
|
+
const label = await promptOptionalText("Label for this step? (blank to skip)");
|
|
1892
|
+
const chunk = await promptOptionalText("Chunk name for this step? (blank to skip)");
|
|
1893
|
+
let decision;
|
|
1894
|
+
if (classify === "constant") {
|
|
1895
|
+
const acknowledgeVaried = await clack.confirm({
|
|
1896
|
+
message: "Acknowledge this value varied across takes anyway?",
|
|
1897
|
+
initialValue: false,
|
|
1898
|
+
});
|
|
1899
|
+
if (clack.isCancel(acknowledgeVaried)) {
|
|
1900
|
+
clack.cancel("postdoc review cancelled");
|
|
1901
|
+
process.exit(1);
|
|
1902
|
+
}
|
|
1903
|
+
decision = { step: ref, classify: "constant", ...(acknowledgeVaried ? { acknowledgeVaried: true } : {}) };
|
|
1904
|
+
}
|
|
1905
|
+
else if (classify === "variable") {
|
|
1906
|
+
const name = await clack.text({ message: "Variable name?" });
|
|
1907
|
+
if (clack.isCancel(name)) {
|
|
1908
|
+
clack.cancel("postdoc review cancelled");
|
|
1909
|
+
process.exit(1);
|
|
1910
|
+
}
|
|
1911
|
+
decision = { step: ref, classify: "variable", name };
|
|
1912
|
+
}
|
|
1913
|
+
else {
|
|
1914
|
+
const prompt = await clack.text({ message: "Handback prompt for the human operator?" });
|
|
1915
|
+
if (clack.isCancel(prompt)) {
|
|
1916
|
+
clack.cancel("postdoc review cancelled");
|
|
1917
|
+
process.exit(1);
|
|
1918
|
+
}
|
|
1919
|
+
decision = { step: ref, classify: "handback", prompt };
|
|
1920
|
+
}
|
|
1921
|
+
if (label !== undefined)
|
|
1922
|
+
decision = { ...decision, label };
|
|
1923
|
+
if (chunk !== undefined)
|
|
1924
|
+
decision = { ...decision, chunk };
|
|
1925
|
+
decisions.push(decision);
|
|
1926
|
+
}
|
|
1927
|
+
clack.outro("review complete");
|
|
1928
|
+
return decisions;
|
|
1929
|
+
}
|
|
1930
|
+
async function promptOptionalText(message) {
|
|
1931
|
+
const value = await clack.text({ message, defaultValue: "" });
|
|
1932
|
+
if (clack.isCancel(value)) {
|
|
1933
|
+
clack.cancel("postdoc review cancelled");
|
|
1934
|
+
process.exit(1);
|
|
1935
|
+
}
|
|
1936
|
+
return value === "" ? undefined : value;
|
|
1937
|
+
}
|
|
1938
|
+
//# sourceMappingURL=program.js.map
|