@proagentstore/cli 0.4.89 → 0.4.90

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.
@@ -63,7 +63,7 @@ export function confirmationMarker(before, after) {
63
63
  if (after.confirmed)
64
64
  return "page_text";
65
65
  const url = urlPathAndQuery(after.url).toLowerCase();
66
- if (CONFIRMATION_URL_RE.test(url) && !!before && urlPathAndQuery(before.url).toLowerCase() !== url)
66
+ if (CONFIRMATION_URL_RE.test(url) && before && urlPathAndQuery(before.url).toLowerCase() !== url)
67
67
  return "url_receipt";
68
68
  // The site's own duplicate notice, but ONLY if it appeared after this click: "you have already
69
69
  // applied" that was on the page beforehand is an earlier application of the owner's, and
@@ -31,6 +31,8 @@ export const LOCAL_APPLY_RUN_PATH = "/local-apply/run";
31
31
  export const LOCAL_APPLY_STATUS_PATH = "/local-apply/status";
32
32
  export const LOCAL_APPLY_RESUME_PATH = "/local-apply/resume";
33
33
  export const LOCAL_APPLY_CANCEL_PATH = "/local-apply/cancel";
34
+ /** Read-only job-page check used before an email lead may enter material preparation. */
35
+ export const LOCAL_APPLY_PREFLIGHT_PATH = "/local-apply/preflight";
34
36
  /** Delivers a cloud-persisted supervisory decision to one paused checkpoint. */
35
37
  export const LOCAL_APPLY_DIRECTIVE_PATH = "/local-apply/directive";
36
38
  export const LOCAL_APPLY_ENGINES = ["claude", "codex"];
@@ -82,13 +84,12 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
82
84
  * dispatch, naming the update, rather than silently producing the older behaviour (`apply.ts`
83
85
  * `runnerContractProblem`).
84
86
  *
85
- * ── Why it moved to 0.4.89 (#994)
87
+ * ── Why it moved to 0.4.90 (#953)
86
88
  *
87
- * #994 makes a post-click confirmation observable and recognises SEEK's "application sent"
88
- * receipt wording. It also carries only closed-vocabulary evidence when a pressed submit remains
89
- * unconfirmed. Those changes live in `packages/browser-runner`, which ships INSIDE the published
90
- * CLI. A machine below this floor cannot provide that evidence, so it must not silently look
91
- * equivalent to a current runner.
89
+ * #953 adds the read-only `/local-apply/preflight` route used before an email lead can enter
90
+ * tailoring. That route lives in `packages/browser-runner`, which ships INSIDE the published CLI.
91
+ * A machine below this floor cannot make the live/expired/unverifiable observation, so it must
92
+ * not silently look equivalent to a current runner.
92
93
  *
93
94
  * A behaviour change in the runner is a CONTRACT change, because the cloud's decisions assume it.
94
95
  * So this floor moves with it: a machine that cannot observe a post-submit receipt is refused
@@ -99,7 +100,7 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
99
100
  * been published. `cliAtLeast` treats an unreported version as capable, which is the convention
100
101
  * every MIN_CLI gate here follows.
101
102
  */
102
- export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.89";
103
+ export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.90";
103
104
  export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output", "submit_unconfirmed"];
104
105
  export const LOCAL_APPLY_CONFIRMATION_MARKERS = ["page_text", "url_receipt", "already_applied_notice"];
105
106
  export const LOCAL_APPLY_SIGNALS = [
@@ -15,12 +15,13 @@
15
15
  * - the result: `awaiting_review`, `submitted` (only with the site's confirmation), `blocked`
16
16
  * with the reason, or `failed`.
17
17
  *
18
- * Runs live in memory: a runner restart loses them, and `status` answers 404, which PAGS reads as
19
- * "the runner lost this run".
18
+ * Active runs live in memory: a runner restart loses them, and `status` answers 404, which PAGS
19
+ * reads as "the runner lost this run". Terminal typed/redacted results are journaled locally so a
20
+ * restart after completion does not erase observed evidence before PAGS pulls it.
20
21
  */
21
22
  import { spawn as nodeSpawn } from "node:child_process";
22
23
  import { createHash, randomBytes } from "node:crypto";
23
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
24
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
24
25
  import { homedir } from "node:os";
25
26
  import { join, resolve, sep } from "node:path";
26
27
  import { fileURLToPath } from "node:url";
@@ -34,7 +35,7 @@ import { finalText, missingLogin, signInHelp } from "../local-browser/engine.js"
34
35
  import { resolveWorkspacePath } from "../local-browser/runtime.js";
35
36
  import { ApplyBridge } from "./bridge.js";
36
37
  import { confirmationSignals } from "./confirmation.js";
37
- import { LOCAL_APPLY_AUTH_MODES, LOCAL_APPLY_CAPS, LOCAL_APPLY_ENGINES, LOCAL_APPLY_MODES, LOCAL_APPLY_TASK_TYPE, } from "./contract.js";
38
+ import { LOCAL_APPLY_AUTH_MODES, LOCAL_APPLY_CAPS, LOCAL_APPLY_ENGINES, LOCAL_APPLY_MODES, LOCAL_APPLY_TASK_TYPE, parseLocalApplyResult, } from "./contract.js";
38
39
  import { applyPrompt, buildApplyEngineSpec, observedApplyAuth } from "./engine.js";
39
40
  const MAX_EVENTS = 1000;
40
41
  const OUTPUT_LINES = 400;
@@ -182,6 +183,104 @@ export class LocalApplyRuntime {
182
183
  this.now = deps.now ?? Date.now;
183
184
  this.spawn = deps.spawn ?? nodeSpawn;
184
185
  this.retentionMs = deps.retentionMs ?? 24 * 60 * 60 * 1000;
186
+ this.hydrateTerminalJournals();
187
+ }
188
+ /**
189
+ * Read a specific job page before an email alert can cause any materials to be generated.
190
+ * This deliberately creates no application run, launches no CLI, and never clicks/fills: the
191
+ * only evidence it returns is a closed, redacted vocabulary the API can durably record.
192
+ */
193
+ async preflight(raw) {
194
+ const input = raw && typeof raw === "object" ? raw : {};
195
+ const jobUrl = typeof input.jobUrl === "string" ? input.jobUrl.trim() : "";
196
+ let parsed;
197
+ try {
198
+ parsed = new URL(jobUrl);
199
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
200
+ throw new Error("scheme");
201
+ }
202
+ catch {
203
+ return { state: "unverifiable", jobUrl, reason: "navigation_failed" };
204
+ }
205
+ const profile = input.browserProfile === "default" ? "default" : "isolated";
206
+ const dir = join(this.root, "preflight", randomBytes(12).toString("hex"));
207
+ let browser = null;
208
+ try {
209
+ browser = await this.deps.browserFor(profile, dir);
210
+ const navigated = await browser.tools.callTool("browser_navigate", { url: parsed.toString() });
211
+ if (navigated.isError)
212
+ return { state: "unverifiable", jobUrl: parsed.toString(), reason: "navigation_failed" };
213
+ const snapshot = await browser.tools.callTool("browser_snapshot");
214
+ const page = (snapshot.content ?? []).map((part) => part.text ?? "").join("\n");
215
+ if (snapshot.isError || /captcha|verify (?:you(?:'| a)re|that you are) human|access denied|unusual traffic|checking (?:if )?your browser/i.test(page)) {
216
+ return { state: "unverifiable", jobUrl: parsed.toString(), reason: "access_blocked" };
217
+ }
218
+ if (/(?:job|position|posting|role|opportunity).{0,120}(?:has )?(?:expired|closed|been filled|is no longer available|is no longer accepting applications)|(?:expired|closed|no longer available|no longer accepting applications).{0,120}(?:job|position|posting|role|opportunity)/i.test(page)) {
219
+ return { state: "unavailable", jobUrl: parsed.toString(), reason: /expired/i.test(page) ? "expired" : "unavailable" };
220
+ }
221
+ // An accessible Apply control is an active path, unlike a generic careers/search URL.
222
+ if (/(?:button|link)\s+"[^"]{0,80}\bapply(?: now| for| here)?\b|\bapply (?:now|for (?:this )?(?:job|role|position))\b|\bstart application\b/i.test(page)) {
223
+ return { state: "live", jobUrl: parsed.toString(), applyUrl: parsed.toString(), evidence: "apply_control_present" };
224
+ }
225
+ return { state: "unverifiable", jobUrl: parsed.toString(), reason: "no_active_apply_path" };
226
+ }
227
+ catch {
228
+ return { state: "unverifiable", jobUrl: parsed.toString(), reason: "navigation_failed" };
229
+ }
230
+ finally {
231
+ await browser?.stop().catch(() => undefined);
232
+ }
233
+ }
234
+ journalPath(dir) {
235
+ return join(dir, "terminal-result.json");
236
+ }
237
+ /** Write-before-return: a terminal result is durable before `status` can expose it. */
238
+ persistTerminal(run) {
239
+ if (!run.result || !run.endedAt)
240
+ return;
241
+ const journal = { version: 1, envelope: run.envelope, events: run.events, seq: run.seq, result: run.result, endedAt: run.endedAt };
242
+ const path = this.journalPath(run.dir);
243
+ const tmp = `${path}.${randomBytes(8).toString("hex")}.tmp`;
244
+ writeFileSync(tmp, JSON.stringify(journal), { mode: 0o600 });
245
+ renameSync(tmp, path);
246
+ }
247
+ /** Hydrate terminal-only records. Interrupted runs remain deliberately unrecoverable. */
248
+ hydrateTerminalJournals() {
249
+ if (!existsSync(this.root))
250
+ return;
251
+ for (const instance of readdirSync(this.root)) {
252
+ const instanceDir = join(this.root, instance);
253
+ let runIds;
254
+ try {
255
+ runIds = readdirSync(instanceDir);
256
+ }
257
+ catch {
258
+ continue;
259
+ }
260
+ for (const runId of runIds) {
261
+ const dir = join(instanceDir, runId);
262
+ const path = this.journalPath(dir);
263
+ if (!existsSync(path))
264
+ continue;
265
+ try {
266
+ const raw = JSON.parse(readFileSync(path, "utf8"));
267
+ if (raw.version !== 1 || !raw.envelope || !raw.result || !Array.isArray(raw.events) || typeof raw.seq !== "number" || typeof raw.endedAt !== "number")
268
+ continue;
269
+ const envelope = parseApplyEnvelope(raw.envelope);
270
+ const parsed = parseLocalApplyResult(raw.result);
271
+ if ("error" in parsed || parsed.result.runId !== envelope.runId)
272
+ continue;
273
+ const checked = parsed.result;
274
+ this.runs.set(envelope.runId, {
275
+ envelope, dir, token: "", state: "ended", events: raw.events, seq: raw.seq,
276
+ result: checked, allow: new Set(envelope.policy.allowDomains), grounding: [], secrets: [], activeSince: raw.endedAt,
277
+ activeMs: 0, waiters: [], output: [], engineAuth: checked.engineAuth, cancelled: false, timedOut: false,
278
+ checkpoints: new Map(), endedAt: raw.endedAt,
279
+ });
280
+ }
281
+ catch { /* a corrupt local cache is never an authority */ }
282
+ }
283
+ }
185
284
  }
186
285
  start(raw) {
187
286
  const envelope = parseApplyEnvelope(raw);
@@ -614,6 +713,9 @@ export class LocalApplyRuntime {
614
713
  // nothing here is text: counts, an exit code, and signal ids this platform defines.
615
714
  ...(r.diagnostic ? { diagnostic: r.diagnostic } : {}),
616
715
  };
716
+ // This must happen before stopping the browser or exposing `ended`: a process restart in the
717
+ // narrow handoff window must not turn an observed result into `runner_lost` in the cloud.
718
+ this.persistTerminal(run);
617
719
  if (run.child && run.child.exitCode === null)
618
720
  this.kill(run);
619
721
  void run.browser?.stop().catch(() => undefined);
@@ -32,6 +32,7 @@ export const LOCAL_ARTIFACT_BLOCK_REASONS = [
32
32
  "workspace_unavailable",
33
33
  "missing_information",
34
34
  "uncertain_claim",
35
+ "invalid_cli_output",
35
36
  "engine_not_signed_in",
36
37
  "api_key_refused",
37
38
  ];
@@ -48,6 +49,7 @@ export const LOCAL_ARTIFACT_EVENT_TYPES = [
48
49
  "note",
49
50
  ];
50
51
  export const LOCAL_ARTIFACT_ENGINE_AUTHS = ["machine-login", "subscription", "api-key", "missing_login", "unknown"];
52
+ export const LOCAL_ARTIFACT_PARSE_ATTEMPTS = ["fenced_json", "balanced_object"];
51
53
  export const LOCAL_ARTIFACT_CAPS = { questions: 20, questionChars: 300, sources: 6, sourceBytes: 256 * 1024 };
52
54
  /** An id used as a folder name: no separators, no dots, nothing a path could be built out of. */
53
55
  export const PATH_SEGMENT = /^[A-Za-z0-9_-]{1,100}$/;
@@ -88,6 +90,7 @@ export function parseLocalArtifactLead(raw) {
88
90
  const leadUrl = str(o.leadUrl, 2000) ?? "";
89
91
  if (leadUrl && !/^https?:\/\/[^\s/]+/i.test(leadUrl))
90
92
  return { error: "the lead's URL is not an http(s) URL" };
93
+ const workKey = str(o.workKey, 600);
91
94
  const requestedAt = str(o.requestedAt, 40);
92
95
  if (!requestedAt || Number.isNaN(Date.parse(requestedAt)))
93
96
  return { error: "the lead has no requestedAt time" };
@@ -101,9 +104,9 @@ export function parseLocalArtifactLead(raw) {
101
104
  if (v)
102
105
  lead[f] = v;
103
106
  }
104
- return { lead: { eventId, sourceInstanceId, leadId, leadUrl, lifecycleVersion, requestedAt, lead } };
107
+ return { lead: { eventId, sourceInstanceId, leadId, leadUrl, ...(workKey ? { workKey } : {}), lifecycleVersion, requestedAt, lead } };
105
108
  }
106
- const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "role", "path", "sha256", "bytes", "kind", "total", "unmatched", "reason", "exitCode", "count", "removed", "status"]);
109
+ const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "role", "path", "sha256", "bytes", "kind", "total", "unmatched", "reason", "exitCode", "count", "removed", "status", "validationError", "parseAttempts", "rawOutputChars", "runId", "attemptNumber"]);
107
110
  /** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
108
111
  export function parseLocalArtifactEvent(raw) {
109
112
  if (!raw || typeof raw !== "object")
@@ -181,6 +184,25 @@ export function parseLocalArtifactResult(raw) {
181
184
  .filter((q) => typeof q === "string" && q.trim() !== "")
182
185
  .slice(0, LOCAL_ARTIFACT_CAPS.questions)
183
186
  .map((q) => q.trim().slice(0, LOCAL_ARTIFACT_CAPS.questionChars));
187
+ const diagnostic = o.diagnostic && typeof o.diagnostic === "object" ? o.diagnostic : null;
188
+ const validationError = diagnostic?.validationError;
189
+ if (diagnostic && (validationError === "no_json_object" || validationError === "invalid_json" || validationError === "not_draft_object" || validationError === "incomplete_draft" || validationError === "unverified_claim")) {
190
+ const parseAttempts = [];
191
+ if (Array.isArray(diagnostic.parseAttempts)) {
192
+ for (let index = 0; index < diagnostic.parseAttempts.length && index < 8; index += 1) {
193
+ const parseAttempt = oneOf(LOCAL_ARTIFACT_PARSE_ATTEMPTS, diagnostic.parseAttempts[index]);
194
+ if (parseAttempt && !parseAttempts.includes(parseAttempt))
195
+ parseAttempts.push(parseAttempt);
196
+ }
197
+ }
198
+ out.diagnostic = {
199
+ validationError,
200
+ parseAttempts,
201
+ rawOutputChars: typeof diagnostic.rawOutputChars === "number" ? Math.max(0, Math.min(diagnostic.rawOutputChars, 200_000)) : 0,
202
+ runId: str(diagnostic.runId, 100) ?? runId,
203
+ attemptNumber: typeof diagnostic.attemptNumber === "number" && Number.isInteger(diagnostic.attemptNumber) && diagnostic.attemptNumber > 0 ? diagnostic.attemptNumber : 1,
204
+ };
205
+ }
184
206
  }
185
207
  if (outcome === "failed")
186
208
  out.error = str(o.error, 1000) ?? "The run failed without a reason.";
@@ -19,8 +19,8 @@
19
19
  */
20
20
  import { mergeEnv } from "../coding/engine-env.js";
21
21
  import { resolveEngineAuth } from "../coding/engine-auth.js";
22
- import { finalText, missingLogin } from "../local-browser/engine.js";
23
- export { finalText, missingLogin };
22
+ import { finalText as finalTextFromEngine, missingLogin } from "../local-browser/engine.js";
23
+ export { missingLogin };
24
24
  const PROVIDER_KEYS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
25
25
  const SUBSCRIPTION_TOKEN = "CLAUDE_CODE_OAUTH_TOKEN";
26
26
  /** The engine's environment: the machine's, never with a provider API key. */
@@ -84,19 +84,64 @@ export function tailorPrompt(lead, sources) {
84
84
  '{"status":"ready","resume_markdown":"…","cover_letter_markdown":"…","claims":[{"text":"…","source":"resume","quote":"…"}]}',
85
85
  ].join("\n");
86
86
  }
87
- /** The CLI's answer: the first JSON object in its final text, fences and prose around it ignored. */
88
- export function parseDraft(text) {
89
- const start = text.indexOf("{");
90
- const end = text.lastIndexOf("}");
91
- if (start < 0 || end <= start)
92
- return null;
93
- try {
94
- const v = JSON.parse(text.slice(start, end + 1));
95
- return v && typeof v === "object" && !Array.isArray(v) ? v : null;
87
+ /** JSON objects from fenced blocks or prose, while respecting braces inside JSON strings. */
88
+ function jsonCandidates(text) {
89
+ const candidates = [];
90
+ for (const match of text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi))
91
+ candidates.push({ source: "fenced_json", text: match[1].trim() });
92
+ for (let start = 0; start < text.length; start++) {
93
+ if (text[start] !== "{")
94
+ continue;
95
+ let quoted = false, escaped = false, depth = 0;
96
+ for (let end = start; end < text.length; end++) {
97
+ const ch = text[end];
98
+ if (quoted) {
99
+ if (escaped)
100
+ escaped = false;
101
+ else if (ch === "\\")
102
+ escaped = true;
103
+ else if (ch === '"')
104
+ quoted = false;
105
+ continue;
106
+ }
107
+ if (ch === '"')
108
+ quoted = true;
109
+ else if (ch === "{")
110
+ depth++;
111
+ else if (ch === "}" && --depth === 0) {
112
+ candidates.push({ source: "balanced_object", text: text.slice(start, end + 1) });
113
+ break;
114
+ }
115
+ }
96
116
  }
97
- catch {
98
- return null;
117
+ return candidates;
118
+ }
119
+ /** Prefer the structured closing message, but tolerate a CLI that printed a JSON object directly.
120
+ * Non-JSON output must stay empty so login and process-failure handling remains fail-closed. */
121
+ export function finalText(engine, lines) {
122
+ const structured = finalTextFromEngine(engine, lines);
123
+ if (structured)
124
+ return structured;
125
+ const raw = lines.join("\n");
126
+ return jsonCandidates(raw).length ? raw : "";
127
+ }
128
+ /** The CLI's answer, with a closed diagnostic instead of a generic parsing collapse. */
129
+ export function parseDraft(text) {
130
+ const candidates = jsonCandidates(text);
131
+ if (!candidates.length)
132
+ return { draft: null, validationError: "no_json_object", parseAttempts: ["fenced_json", "balanced_object"] };
133
+ let invalid = false;
134
+ for (const candidate of candidates) {
135
+ try {
136
+ const parsed = JSON.parse(candidate.text);
137
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "status" in parsed)
138
+ return { draft: parsed, parseAttempts: [candidate.source] };
139
+ }
140
+ catch {
141
+ invalid = true;
142
+ }
99
143
  }
144
+ return { draft: null, validationError: invalid ? "invalid_json" : "not_draft_object", parseAttempts: [...new Set(candidates.map((c) => c.source))] };
100
145
  }
101
146
  /** Case, whitespace, quote-style and dash-style insensitive — so a faithful quote is never failed on typography. */
102
147
  export function normalize(s) {
@@ -117,18 +162,19 @@ const digits = (s) => s.replace(/\D/g, "");
117
162
  * Check the CLI's draft against the sources. Questions name WHAT to confirm, bounded, and are
118
163
  * returned only to the owner's own record — never to a trace.
119
164
  */
120
- export function checkDraft(draft, sources) {
165
+ export function checkDraft(parsed, sources) {
166
+ const draft = parsed.draft;
121
167
  if (!draft)
122
- return { ok: false, reason: "uncertain_claim", questions: ["The CLI did not return a readable draft. Run it again, or check the source files."], claims: 0, unmatched: 0 };
168
+ return { ok: false, reason: "invalid_cli_output", questions: [`The CLI did not return a readable application JSON object (${parsed.validationError ?? "unknown"}). Retry tailoring after checking the CLI output format.`], claims: 0, unmatched: 0, validationError: parsed.validationError ?? "no_json_object", parseAttempts: parsed.parseAttempts };
123
169
  if (draft.status === "needs_human") {
124
170
  const qs = (Array.isArray(draft.questions) ? draft.questions : []).filter((q) => typeof q === "string" && q.trim() !== "").map((q) => q.trim());
125
- return { ok: false, reason: "missing_information", questions: qs.length ? qs : ["The CLI needs more information but did not say what."], claims: 0, unmatched: 0 };
171
+ return { ok: false, reason: "missing_information", questions: qs.length ? qs : ["The CLI needs more information but did not say what."], claims: 0, unmatched: 0, validationError: "incomplete_draft", parseAttempts: parsed.parseAttempts };
126
172
  }
127
173
  const resume = typeof draft.resume_markdown === "string" ? draft.resume_markdown.trim() : "";
128
174
  const coverLetter = typeof draft.cover_letter_markdown === "string" ? draft.cover_letter_markdown.trim() : "";
129
175
  const claims = Array.isArray(draft.claims) ? draft.claims : [];
130
176
  if (draft.status !== "ready" || !resume || !coverLetter || !claims.length) {
131
- return { ok: false, reason: "uncertain_claim", questions: ["The draft was incomplete (missing a document or its source citations). Run it again."], claims: claims.length, unmatched: 0 };
177
+ return { ok: false, reason: "invalid_cli_output", questions: ["The CLI JSON was incomplete (missing a document or its source citations). Run it again."], claims: claims.length, unmatched: 0, validationError: "incomplete_draft", parseAttempts: parsed.parseAttempts };
132
178
  }
133
179
  const byRole = new Map(sources.map((s) => [s.role, normalize(s.text)]));
134
180
  const all = normalize(sources.map((s) => s.text).join("\n"));
@@ -161,6 +207,6 @@ export function checkDraft(draft, sources) {
161
207
  if (digits(p).length >= 9 && !sourceDigits.includes(digits(p)))
162
208
  flag("Confirm the phone number in the draft — it does not appear in your sources.");
163
209
  if (unmatched)
164
- return { ok: false, reason: "uncertain_claim", questions, claims: claims.length, unmatched };
210
+ return { ok: false, reason: "uncertain_claim", questions, claims: claims.length, unmatched, validationError: "unverified_claim", parseAttempts: parsed.parseAttempts };
165
211
  return { ok: true, resume, coverLetter, claims: claims.length };
166
212
  }
@@ -273,10 +273,19 @@ export class LocalArtifactRuntime {
273
273
  return;
274
274
  }
275
275
  const check = checkDraft(parseDraft(text), ctx.texts);
276
- this.emit(run, { type: "claims.checked", detail: { total: check.claims, unmatched: check.ok ? 0 : check.unmatched } });
276
+ const retry = /:retry:(\d+)$/.exec(e.requestId);
277
+ const attemptNumber = retry ? Number(retry[1]) : 1;
278
+ const diagnostic = check.ok ? undefined : {
279
+ validationError: check.validationError,
280
+ parseAttempts: check.parseAttempts,
281
+ rawOutputChars: text.length,
282
+ runId: e.runId,
283
+ attemptNumber,
284
+ };
285
+ this.emit(run, { type: "claims.checked", detail: { total: check.claims, unmatched: check.ok ? 0 : check.unmatched, ...(diagnostic ? { validationError: diagnostic.validationError, parseAttempts: diagnostic.parseAttempts.join(","), rawOutputChars: diagnostic.rawOutputChars, runId: diagnostic.runId, attemptNumber } : {}) } });
277
286
  if (!check.ok) {
278
287
  this.removeIfEmpty(ctx.outDir);
279
- this.pauseEnd(run, check.reason, check.questions);
288
+ this.pauseEnd(run, check.reason, check.questions, diagnostic);
280
289
  return;
281
290
  }
282
291
  const at = new Date(this.now()).toISOString();
@@ -340,9 +349,9 @@ export class LocalArtifactRuntime {
340
349
  return;
341
350
  run.events.push({ ...ev, at: new Date(this.now()).toISOString(), seq: ++run.seq });
342
351
  }
343
- pauseEnd(run, reason, questions) {
344
- this.emit(run, { type: "run.needs_human", detail: { reason, count: questions.length } });
345
- this.end(run, { outcome: "needs_human", blockReason: reason, questions });
352
+ pauseEnd(run, reason, questions, diagnostic) {
353
+ this.emit(run, { type: "run.needs_human", detail: { reason, count: questions.length, ...(diagnostic ? { validationError: diagnostic.validationError, parseAttempts: diagnostic.parseAttempts.join(","), rawOutputChars: diagnostic.rawOutputChars, runId: diagnostic.runId, attemptNumber: diagnostic.attemptNumber } : {}) } });
354
+ this.end(run, { outcome: "needs_human", blockReason: reason, questions, diagnostic });
346
355
  }
347
356
  end(run, r) {
348
357
  if (run.state === "ended")
@@ -360,7 +369,7 @@ export class LocalArtifactRuntime {
360
369
  engineAuth: run.engineAuth,
361
370
  traceId: run.envelope.runId,
362
371
  ...(r.generatedAt ? { generatedAt: r.generatedAt } : {}),
363
- ...(r.outcome === "needs_human" ? { blockReason: r.blockReason, questions: (r.questions ?? []).map((q) => redactText(q, run.secrets)) } : {}),
372
+ ...(r.outcome === "needs_human" ? { blockReason: r.blockReason, questions: (r.questions ?? []).map((q) => redactText(q, run.secrets)), ...(r.diagnostic ? { diagnostic: r.diagnostic } : {}) } : {}),
364
373
  // CLI output becomes this text, and a CLI prints whatever it was given: redacted before it is kept.
365
374
  ...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
366
375
  };
@@ -226,6 +226,10 @@ async function route(runner, req, res) {
226
226
  return json(res, 200, runner.localArtifact.cancel(await readJson(req)));
227
227
  }
228
228
  // Local application execution (#957) — run, status, resume, cancel; PULL like research.
229
+ // The email-lead preflight is read-only and precedes Tailor material generation (#953).
230
+ if (req.method === "POST" && path === "/local-apply/preflight") {
231
+ return json(res, 200, await runner.localApply.preflight(await readJson(req)));
232
+ }
229
233
  if (req.method === "POST" && path === "/local-apply/run") {
230
234
  return json(res, 202, runner.localApply.start(await readJson(req)));
231
235
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.89",
3
+ "version": "0.4.90",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",