@proagentstore/cli 0.4.83 → 0.4.85

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.
@@ -24,7 +24,8 @@
24
24
  *
25
25
  * The trace carries classes, domains, decisions and artifact handles — never a typed value.
26
26
  */
27
- import { ELEMENT_PROBE_FN, FALLBACK_COMMIT_RE } from "../commit-guard.js";
27
+ import { classifyApplyClick } from "./contract.js";
28
+ import { ELEMENT_PROBE_FN } from "../commit-guard.js";
28
29
  import { normalize } from "../local-artifact/engine.js";
29
30
  import { evaluateResult, hostOf, refRole } from "../local-browser/bridge.js";
30
31
  const READ_TOOLS = new Set(["browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_wait_for"]);
@@ -105,6 +106,7 @@ function withQuote(schema, required) {
105
106
  export class ApplyBridge {
106
107
  browser;
107
108
  host;
109
+ /** Read by the runtime for the #975 diagnostic: 0 calls is the finding, not an internal detail. */
108
110
  actions = 0;
109
111
  pages = 0;
110
112
  lastSnapshot = "";
@@ -328,9 +330,24 @@ export class ApplyBridge {
328
330
  const facts = await this.probe(t.ref, t.name);
329
331
  if (!facts)
330
332
  return this.refuse("browser_click", "read", "That element could not be read from the page, so the click cannot be shown to be safe. Take a fresh browser_snapshot.");
331
- const commits = (facts.submits && facts.method === "post") || [facts.name, t.name, String(forward.element)].some((n) => n && FALLBACK_COMMIT_RE.test(n));
332
- if (commits)
333
- return this.submit(forward);
333
+ // WHICH control is this? (#985) The rule is in the shared contract, which states the three
334
+ // families and why the order of the tests is the safety property. It replaced a test against
335
+ // `FALLBACK_COMMIT_RE` — the READ-ONLY floor, which matches bare `apply` on purpose — under
336
+ // which the press that OPENS a SEEK application was the final submit, and four live
337
+ // fill-and-review runs ended `awaiting_review` with `filled: 0` seconds after the cloud had
338
+ // let them through their initial checkpoint.
339
+ const klass = classifyApplyClick({
340
+ role: t.role,
341
+ names: [facts.name, t.name, String(forward.element)],
342
+ submits: facts.submits,
343
+ method: facts.method,
344
+ filled: this.filled,
345
+ uploaded: this.uploaded.size,
346
+ });
347
+ if (klass.klass === "submit")
348
+ return this.submit(forward, klass.reason);
349
+ if (klass.klass === "entry")
350
+ return this.entry(forward, t, klass.reason);
334
351
  if (NAV_ROLES.has(t.role) || (t.role === "button" && PAGINATION_NAME.test(t.name.trim()))) {
335
352
  if (this.pages >= this.host.limits.maxPages)
336
353
  return this.refuse("browser_click", "read", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call ready_for_review now.`);
@@ -341,7 +358,7 @@ export class ApplyBridge {
341
358
  }
342
359
  // A native file input opens the file chooser for upload_artifact — a DOM fact, whatever its label.
343
360
  const fileInput = facts.tag === "input" && facts.type === "file";
344
- if (fileInput || (t.role === "button" && STEP_NAME.test(t.name.trim()) && !facts.submits)) {
361
+ if (fileInput || klass.klass === "step" || (t.role === "button" && STEP_NAME.test(t.name.trim()) && !facts.submits)) {
345
362
  const checkpoint = this.requireSupervisor("browser_click", "fill");
346
363
  if (checkpoint)
347
364
  return checkpoint;
@@ -507,20 +524,76 @@ export class ApplyBridge {
507
524
  return text(`Recorded the runner-verified ${reason} listing notice. Stop now.`);
508
525
  }
509
526
  review(summary) {
510
- this.reviewReady = true;
511
527
  this.summary = summary; // bounded where the result is built (runtime.ts `end`)
528
+ // #989: "ready for review" with nothing entered is not a review state.
529
+ //
530
+ // The live run ended `awaiting_review` with `filled: 0, uploaded: 0` seconds after the cloud
531
+ // had let it through its initial checkpoint, because a refused submit-class click called
532
+ // this. An owner reading that is told a form is populated and waiting for them — and the one
533
+ // action it invites (#981's approve-and-continue) would send an empty application. There is
534
+ // no form to review, so the honest terminal outcome is the recorded blocker and its remedy
535
+ // (`runtime.ts` `outcomeOf` reads `blocked` once `reviewReady` is false).
536
+ if (!this.filled && !this.uploaded.size && this.blocked) {
537
+ this.host.emit({ type: "policy.decision", detail: { tool: "ready_for_review", class: "review", decision: "refused", reason: this.blocked.reason, count: 0 } });
538
+ return text(`Nothing was entered on this application, so there is nothing to review: the run is recorded as ${this.blocked.reason.replace(/_/g, " ")}. Stop now.`, true);
539
+ }
540
+ this.reviewReady = true;
512
541
  this.host.emit({ type: "review.ready", detail: { class: "review", count: this.filled } });
513
542
  return text("Recorded. The application waits for the owner's review; nothing was submitted. Stop now.");
514
543
  }
544
+ /**
545
+ * The control that OPENS the application (#985) — pressed, never submitted.
546
+ *
547
+ * It needs the supervisor's live `continue` exactly as a fill does: it changes the page, and the
548
+ * cloud is the party that decides whether this page may be worked on. `afterMove` then counts the
549
+ * page, traces it and clears the approval, so the form that opens gets its own checkpoint — which
550
+ * is what keeps a multi-page ATS supervised page by page rather than once at the start.
551
+ */
552
+ async entry(forward, t, reason) {
553
+ const checkpoint = this.requireSupervisor("browser_click", "fill");
554
+ if (checkpoint)
555
+ return checkpoint;
556
+ if (this.pages >= this.host.limits.maxPages)
557
+ return this.refuse("browser_click", "read", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call ready_for_review now.`);
558
+ this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "entry", decision: "allowed", reason, role: t.role } });
559
+ const res = await this.browser.callTool("browser_click", forward);
560
+ if (res.isError)
561
+ return { content: [{ type: "text", text: textOf(res) }], isError: true };
562
+ return this.afterMove("browser_click", textOf(res));
563
+ }
515
564
  /** The final Submit. Never under fill_and_review; once, gated and traced, under auto_submit. */
516
- async submit(forward) {
565
+ async submit(forward, why = "terminal_label") {
517
566
  const checkpoint = this.requireSupervisor("browser_click", "submit");
518
567
  if (checkpoint)
519
568
  return checkpoint;
520
569
  if (this.host.mode !== "auto_submit" || !this.host.gateId) {
521
- this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "submit", decision: "refused", reason: "fill_and_review" } });
522
- this.review("Stopped at the final submit: this run fills and waits for review.");
523
- return text("That control submits the application, and this run is fill-and-review: it was NOT pressed. The application waits for the owner's review. Stop now.");
570
+ // `reason` carries WHICH rule classified this as the submit (#985). "fill_and_review"
571
+ // alone left an owner with a run that stopped at `filled: 0` and no way to tell whether it
572
+ // had reached the end of the form or refused the button that opens it.
573
+ // The RULE, not the control's label: the label is the page's own prose and belongs to the
574
+ // machine (the contract keeps page text off the cloud trace). The rule is what an owner
575
+ // needs — "this was the one-click control", not "this was a submit".
576
+ this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "submit", decision: "refused", reason: "fill_and_review", rule: why } });
577
+ const oneClick = why === "one_click_apply";
578
+ // #989: a refusal with NOTHING entered is not a review state, so it is recorded as the
579
+ // blocker it is. `review` then declines to mark the run ready (see there), and the owner
580
+ // gets a terminal outcome they can act on — approve this application, or open it
581
+ // themselves — instead of an empty form presented as waiting for their approval. With
582
+ // work on the page the old reading holds: there IS a filled form to look at.
583
+ if (!this.filled && !this.uploaded.size) {
584
+ this.blocked = {
585
+ reason: "incomplete",
586
+ questions: [
587
+ oneClick
588
+ ? "This listing's apply control can send the application in one click, so a fill-and-review run may not press it, and nothing was entered. Approve this application to let it be sent, or apply on the site yourself."
589
+ : "The run reached the control that submits this application without entering anything — the form may already be complete from your profile, or it may not have opened. Approve this application to let it be sent, or open it yourself.",
590
+ ],
591
+ };
592
+ }
593
+ this.review(oneClick ? "Stopped at a one-click apply control: it can send the application outright, and this run fills and waits for review." : "Stopped at the final submit: this run fills and waits for review.");
594
+ return text(oneClick
595
+ ? 'That control ("' + String(forward.element ?? "") + '") can send the application in one click, and this run is fill-and-review: it was NOT pressed. Approve this application if you want it sent. Stop now.'
596
+ : "That control submits the application, and this run is fill-and-review: it was NOT pressed. The application waits for the owner's review. Stop now.");
524
597
  }
525
598
  const before = await this.inspect();
526
599
  if (!before)
@@ -56,7 +56,57 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
56
56
  "source_unavailable",
57
57
  "artifact_changed",
58
58
  "job_unavailable",
59
+ "bridge_unused",
59
60
  ];
61
+ /**
62
+ * Why a run produced nothing on the page (#975) — a CLOSED vocabulary, never free text.
63
+ *
64
+ * The production case: the CLI authenticated, exited 0 after 51 seconds, and emitted no browser,
65
+ * checkpoint, fill or review event. The outcome said `incomplete`, which is also what a half-filled
66
+ * form says, so the operator had no way to tell "it did nothing" from "it stopped partway" — and no
67
+ * way at all to learn WHY without reading a terminal on the machine.
68
+ *
69
+ * Deliberately NOT an output tail, redacted or otherwise. `redactText` removes credential SHAPES; it
70
+ * cannot remove the owner's own résumé prose, their typed answers or a quoted source document, all
71
+ * of which a CLI legitimately prints while it works. So nothing free-form is added here: every field
72
+ * is a count, a code, or an id from {@link LOCAL_APPLY_SIGNALS} that the RUNNER chose by matching
73
+ * its own output. The engine's closing sentence continues to cross as `summary`, where it already
74
+ * did — redacted and capped — so this adds diagnosis without adding a new channel for content.
75
+ */
76
+ /**
77
+ * The first published CLI whose runner executes THIS contract (#977, raised at #989).
78
+ *
79
+ * An older runner is not broken; it simply predates the vocabulary. Left to run, it reports the
80
+ * same failure in the PREVIOUS shape, which is indistinguishable from a run under the new contract
81
+ * — exactly the live confusion #977 was filed from. So a machine below this is refused BEFORE
82
+ * dispatch, naming the update, rather than silently producing the older behaviour (`apply.ts`
83
+ * `runnerContractProblem`).
84
+ *
85
+ * ── Why it moved to 0.4.85 (#989)
86
+ *
87
+ * #985 fixed the runner's click classification — the press that OPENS a SEEK application stopped
88
+ * being read as the final submit ({@link classifyApplyClick}) — and that fix lives in
89
+ * `packages/browser-runner`, which ships INSIDE the published CLI. The commit did not bump
90
+ * `packages/cli/package.json`, and `publish-npm.yml` publishes only a version that is new, so
91
+ * nothing was published: the live machine kept running 0.4.84, whose bundled bridge still tests
92
+ * `FALLBACK_COMMIT_RE`. The next live run therefore reproduced the defect the fix had already
93
+ * removed from `main` — `browser_click → class: submit → refused: fill_and_review`, no `rule`
94
+ * field, three seconds after the cloud's own `continue` — and the only durable evidence that the
95
+ * code was old was the absence of a field nobody looks for.
96
+ *
97
+ * A behaviour change in the runner is a CONTRACT change, because the cloud's decisions assume it.
98
+ * So this floor moves with it: a machine that cannot tell the entry control from the final submit
99
+ * is refused with a sentence that says so, instead of ending `awaiting_review` having typed
100
+ * nothing.
101
+ *
102
+ * Bump this when the runner's half of the contract changes again, together with the CLI version
103
+ * that ships it — `policy.test.ts` pins the pair, so the floor cannot name a release that has not
104
+ * been published. `cliAtLeast` treats an unreported version as capable, which is the convention
105
+ * every MIN_CLI gate here follows.
106
+ */
107
+ export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.85";
108
+ export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output"];
109
+ export const LOCAL_APPLY_SIGNALS = ["approval_policy_blocked", "bridge_tools_missing", "auth_prompt", "no_output", "engine_refused_task"];
60
110
  export const LOCAL_APPLY_UNAVAILABLE_REASONS = ["expired", "unavailable"];
61
111
  export const LOCAL_APPLY_EVENT_TYPES = [
62
112
  "engine.auth_checked",
@@ -86,7 +136,11 @@ export const LOCAL_APPLY_CAPS = { questions: 20, questionChars: 300, answers: 20
86
136
  const str = (v, max) => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null);
87
137
  const oneOf = (list, v) => (list.includes(v) ? v : null);
88
138
  const isHttpUrl = (v) => /^https?:\/\/[^\s/]+/i.test(v);
89
- const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "mode", "class", "tool", "decision", "reason", "role", "kind", "path", "sha256", "bytes", "gateId", "exitCode", "count", "status", "basis", "source", "checkpointId", "directive", "phase", "actions", "filled", "uploaded"]);
139
+ // `rule` is on this list from #989: the runner has emitted WHICH rule classified a click as the
140
+ // submit since #985, and this whitelist silently dropped it — so the one fact that tells "it
141
+ // refused the control that opens the form" from "it refused the real submit" never reached the
142
+ // cloud trace, which is where the live investigation needed it.
143
+ const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "mode", "class", "tool", "decision", "reason", "rule", "role", "kind", "path", "sha256", "bytes", "gateId", "exitCode", "count", "status", "basis", "source", "checkpointId", "directive", "phase", "actions", "filled", "uploaded"]);
90
144
  /** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
91
145
  export function parseLocalApplyEvent(raw) {
92
146
  if (!raw || typeof raw !== "object")
@@ -162,6 +216,24 @@ export function parseLocalApplyResult(raw) {
162
216
  out.submitted = { url, at, gateId };
163
217
  out.submitAttempted = true;
164
218
  }
219
+ // #975 — accepted ONLY as the closed vocabulary it declares. A runner that sends prose here gets
220
+ // it dropped rather than stored: the whole point of a structured diagnostic is that no new
221
+ // free-text channel opens between the machine and the owner's records.
222
+ const d = o.diagnostic && typeof o.diagnostic === "object" && !Array.isArray(o.diagnostic) ? o.diagnostic : null;
223
+ const cause = d ? oneOf(LOCAL_APPLY_DIAGNOSTIC_CAUSES, d.cause) : null;
224
+ if (d && cause) {
225
+ const count = (v) => (typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 1_000_000 ? v : 0);
226
+ out.diagnostic = {
227
+ cause,
228
+ bridgeCalls: count(d.bridgeCalls),
229
+ engineExit: typeof d.engineExit === "number" && Number.isInteger(d.engineExit) && d.engineExit >= -1 && d.engineExit <= 255 ? d.engineExit : 0,
230
+ activeMs: count(d.activeMs),
231
+ pages: count(d.pages),
232
+ filled: count(d.filled),
233
+ // Deduped from a closed 5-value vocabulary, so it is bounded by the enum itself — no cap needed.
234
+ signals: [...new Set((Array.isArray(d.signals) ? d.signals : []).filter((x) => LOCAL_APPLY_SIGNALS.includes(x)))],
235
+ };
236
+ }
165
237
  if (outcome === "blocked") {
166
238
  out.blockReason = oneOf(LOCAL_APPLY_BLOCK_REASONS, o.blockReason) ?? "incomplete";
167
239
  out.questions = (Array.isArray(o.questions) ? o.questions : [])
@@ -187,3 +259,104 @@ export function parseLocalApplyResult(raw) {
187
259
  out.error = str(o.error, 1000) ?? "The run failed without a reason.";
188
260
  return { result: out };
189
261
  }
262
+ // ── Which control is the FINAL submit, and which one opens the form (#985) ───────────────────
263
+ /**
264
+ * ── The live failure
265
+ *
266
+ * Four consecutive fill-and-review runs on one SEEK posting ended `awaiting_review` with
267
+ * `filled: 0`. The last of them (`286bbb8a…`, 2026-10-08) got everything right up to the last
268
+ * step: the runner reported its `initial` checkpoint, the cloud's deterministic policy decided
269
+ * `continue` (#982), the directive was delivered and acknowledged and the CLI resumed — and then,
270
+ * three seconds later, the press that OPENS the application was classified as the final submit,
271
+ * refused under `fill_and_review`, and the run ended at review having typed nothing.
272
+ *
273
+ * The cause was a borrowed vocabulary. The bridge tested the control's label against the runner's
274
+ * `FALLBACK_COMMIT_RE` — the READ-ONLY floor, which matches bare `apply` deliberately, because for
275
+ * an agent that may never act, refusing a filter button called "Apply" is the cheap error. For a
276
+ * run whose entire capability is to fill the form it is the expensive one. The cloud's own
277
+ * `commit-guard.ts` had already written the distinction down twice ("Apply/Next/Continue … are the
278
+ * ENTRY button on most multi-page ATS"; the APPLY family is terminal "only after something has
279
+ * been typed") — the apply bridge just had no list of its own to reach for.
280
+ *
281
+ * ── The three families, and why the order of the tests is the safety property
282
+ *
283
+ * 1. ONE-CLICK ({@link ONE_CLICK_SUBMIT_RE}) — "Quick apply", "Easy Apply", "Apply with your
284
+ * profile". On LinkedIn, and on a profile-apply SEEK listing, that control can send the
285
+ * application outright. Tested FIRST and always terminal, because the two families overlap in
286
+ * English and the overlap is only dangerous in one direction: reading "Quick apply" as an entry
287
+ * control is how an unapproved application reaches an employer.
288
+ * 2. TERMINAL ({@link TERMINAL_APPLY_SUBMIT_RE}) — "Submit", "Send application", "Finish", in
289
+ * twenty languages: the control that sends what has been filled.
290
+ * 3. ENTRY ({@link APPLY_ENTRY_RE}) — "Apply", "Apply for this job", "Next", "Continue", and their
291
+ * equivalents: the control that opens or advances the form. Only ever an entry while nothing has
292
+ * been entered yet and the control is not a POST submit; once a field is filled, the APPLY
293
+ * family becomes terminal again, which is the rule the cloud guard already states.
294
+ *
295
+ * Everything outside the three families is decided by the DOM, as before: a native POST submit is
296
+ * a submit. The one case that changed is a POST submit labelled as a STEP with nothing filled yet
297
+ * ("Save and continue" on page 1) — that stays walkable, because a multi-page ATS cannot be filled
298
+ * otherwise, and the final page's control says SUBMIT.
299
+ */
300
+ const applyWordish = (token) => `(?<![\\p{L}\\p{N}])${token}(?![\\p{L}\\p{N}])`;
301
+ /** A control that can SEND the application on its own. Terminal, whatever else it looks like. */
302
+ export const ONE_CLICK_SUBMIT_RE = new RegExp([
303
+ "quick apply|easy apply|instant apply|one[- ]?click|1[- ]?click|apply with (your )?(profile|r[ée]sum[ée]|cv|linkedin|indeed|seek)|apply using (your )?profile",
304
+ ...["schnellbewerbung", "postulation rapide", "candidatura rapida", "snelle sollicitatie"].map(applyWordish),
305
+ ...["快速申请", "簡単応募", "간편지원"],
306
+ ].join("|"), "iu");
307
+ /** The control that SENDS a filled application. Mirrors the cloud guard's terminal English set. */
308
+ export const TERMINAL_APPLY_SUBMIT_RE = new RegExp([
309
+ "\\b(submit|finish|done|complete|confirm)\\b|send application|submit application|send my application",
310
+ ...[
311
+ "envoyer", "soumettre", "valider", "finaliser", "terminer", "confirmer",
312
+ "absenden", "abschicken", "senden", "einreichen", "best[äa]tigen", "abschlie[sß]en", "fertigstellen",
313
+ "enviar", "confirmar", "finalizar", "completar", "submeter", "concluir",
314
+ "invia", "inviare", "conferma", "confermare", "completa", "termina", "finalizza",
315
+ "verstuur", "versturen", "verzenden", "indienen", "bevestig", "bevestigen", "voltooien", "afronden",
316
+ "skicka", "sende", "bekr[äae]fta", "bekreft", "bekr[æa]ft", "slutf[öo]r", "fullf[øo]r", "afslut",
317
+ "wy[śs]lij", "z[łl]ó[żz]", "potwierd[źz]", "zako[ńn]cz", "odeslat", "potvrdit", "dokon[čc]it",
318
+ "g[öo]nder", "onayla", "tamamla", "kirim", "kirimkan", "ajukan", "konfirmasi",
319
+ "g[ửu]i", "n[ộo]p", "отправить", "подтвердить", "завершить", "подать",
320
+ ].map(applyWordish),
321
+ ...["提交", "送出", "确认", "確認", "完成", "送信", "提出", "完了", "제출", "보내기", "확인", "완료", "إرسال", "تقديم", "تأكيد"],
322
+ ].join("|"), "iu");
323
+ /** The control that OPENS or ADVANCES the form — never the one that sends it. */
324
+ export const APPLY_ENTRY_RE = new RegExp([
325
+ "\\b(apply|apply now|apply for this job|apply for this role|view (and )?apply|start( your)? application|begin( your)? application|next|continue|save and continue|start)\\b",
326
+ ...["postuler", "candidater", "bewerben", "candidati", "solliciteer", "aplikuj", "aplicar", "postular", "candidatar", "ans[øo]g", "başvur", "lamar", "откликнуться"].map(applyWordish),
327
+ ...["申请", "应聘", "応募する", "応募", "지원하기", "التقديم"],
328
+ ].join("|"), "iu");
329
+ /**
330
+ * Classify one click. PURE, so the rule that decides whether a real application is sent is
331
+ * assertable without a browser — and identical in both copies of this file.
332
+ *
333
+ * Order is the specification: one-click first (it can send), then terminal, then the DOM's own
334
+ * verdict once anything has been entered, then entry/step.
335
+ */
336
+ export function classifyApplyClick(input) {
337
+ const names = input.names.map((n) => (n ?? "").trim()).filter((n) => n.length > 0);
338
+ const hit = (re) => names.find((n) => re.test(n));
339
+ const entered = input.filled > 0 || input.uploaded > 0;
340
+ const oneClick = hit(ONE_CLICK_SUBMIT_RE);
341
+ if (oneClick)
342
+ return { klass: "submit", reason: "one_click_apply" };
343
+ const terminal = hit(TERMINAL_APPLY_SUBMIT_RE);
344
+ if (terminal)
345
+ return { klass: "submit", reason: "terminal_label" };
346
+ // A native POST submit, once something HAS been entered: there is an application to send, so
347
+ // the benefit of the doubt goes to the employer rather than to the run.
348
+ if (input.submits && input.method === "post" && entered)
349
+ return { klass: "submit", reason: "post_submit_after_fill" };
350
+ const entry = hit(APPLY_ENTRY_RE);
351
+ // Nothing entered yet: an APPLY/NEXT control opens the form. This is the press that ended four
352
+ // live runs at `filled: 0` — and it cannot be the final submit, because nothing has been typed.
353
+ if (entry && !entered)
354
+ return { klass: "entry", reason: input.submits ? "entry_post_form_nothing_filled" : "entry_label_nothing_filled" };
355
+ if (entry)
356
+ return { klass: "step", reason: "step_after_fill" };
357
+ // A POST submit with no recognised label and nothing entered is a page-advance on a multi-page
358
+ // form; with something entered it was caught above.
359
+ if (input.submits && input.method === "post")
360
+ return { klass: "step", reason: "post_submit_nothing_filled" };
361
+ return { klass: "other", reason: "unrecognised" };
362
+ }
@@ -147,6 +147,23 @@ export function verifyArtifact(workspace, home, a) {
147
147
  return { error: `The approved ${label} at ${a.path} could not be read.` };
148
148
  }
149
149
  }
150
+ /**
151
+ * What to tell the owner when the CLI put nothing on the page (#975) — built from the diagnostic's
152
+ * own ids, so the advice follows the observation rather than guessing at it.
153
+ */
154
+ function bridgeUnusedHelp(engine, d) {
155
+ if (d.signals.includes("approval_policy_blocked"))
156
+ return `The ${engine} CLI would not use the browser tools because its own approval policy refused them. Allow the PAGS bridge toolset for this session, then retry.`;
157
+ if (d.signals.includes("bridge_tools_missing"))
158
+ return `The ${engine} CLI never saw the browser tools. Update the CLI (npm i -g @proagentstore/cli), run \`pags up\` again, then retry.`;
159
+ if (d.signals.includes("auth_prompt"))
160
+ return `The ${engine} CLI stopped on a sign-in prompt before opening the page. Sign it in on this machine, then retry.`;
161
+ if (d.signals.includes("no_output"))
162
+ return `The ${engine} CLI produced no output at all before exiting. Check that it runs on this machine, then retry.`;
163
+ if (d.signals.includes("engine_refused_task"))
164
+ return `The ${engine} CLI declined the task and never opened the page. Its own closing message is on this run's summary.`;
165
+ return `The ${engine} CLI ran for ${Math.round(d.activeMs / 1000)}s and exited (code ${d.engineExit}) without opening the application page — no navigation, no checkpoint, no fill. Nothing was submitted. Retry, or run the CLI by hand on this machine to see why it stops.`;
166
+ }
150
167
  export class LocalApplyRuntime {
151
168
  deps;
152
169
  runs = new Map();
@@ -304,6 +321,42 @@ export class LocalApplyRuntime {
304
321
  }, 5_000);
305
322
  run.timer.unref?.();
306
323
  }
324
+ /**
325
+ * What the runner OBSERVED about its own CLI (#975), as ids this platform defines.
326
+ *
327
+ * Matched against the engine's structured output on the machine; only the matching signal's ID
328
+ * crosses the contract, never the line that matched it. That is what makes a diagnostic safe to
329
+ * persist: a CLI prints the owner's résumé prose and their typed answers while it works, and no
330
+ * redaction can reliably tell those from the rest of its chatter.
331
+ */
332
+ signalsOf(run) {
333
+ const text = run.output.join("\n");
334
+ const out = [];
335
+ if (!text.trim())
336
+ out.push("no_output");
337
+ // #952's live failure, invisible from the cloud and fixable by configuration.
338
+ if (/approval policy is\s*[`'"]?never|requires? approval|permission denied for tool|not allowed to use/i.test(text))
339
+ out.push("approval_policy_blocked");
340
+ if (/browser_navigate.{0,40}(not found|unknown tool|unavailable)|no such tool|tool .{0,40}is not available/i.test(text))
341
+ out.push("bridge_tools_missing");
342
+ if (missingLogin(run.envelope.engine, text))
343
+ out.push("auth_prompt");
344
+ if (/\b(i (cannot|can't|won't)|unable to) (help|assist|complete|do that|proceed)/i.test(text))
345
+ out.push("engine_refused_task");
346
+ return [...new Set(out)];
347
+ }
348
+ /** The bounded, closed-vocabulary diagnostic for a run that put nothing on the page (#975). */
349
+ diagnosticOf(run, code, cause) {
350
+ return {
351
+ cause,
352
+ bridgeCalls: run.bridge?.actions ?? 0,
353
+ engineExit: Number.isInteger(code) && code >= -1 && code <= 255 ? code : 0,
354
+ activeMs: this.activeMs(run),
355
+ pages: run.bridge?.pages ?? 0,
356
+ filled: run.bridge?.filled ?? 0,
357
+ signals: this.signalsOf(run),
358
+ };
359
+ }
307
360
  /** What the run amounts to when the CLI exits — decided by what the bridge did, not what the CLI says. */
308
361
  outcomeOf(run, code) {
309
362
  const e = run.envelope;
@@ -323,10 +376,26 @@ export class LocalApplyRuntime {
323
376
  return { outcome: "blocked", summary, blockReason: "engine_not_signed_in", questions: [signInHelp(e.engine)], engineAuth: "missing_login" };
324
377
  if (b?.blocked)
325
378
  return { outcome: "blocked", summary, blockReason: b.blocked.reason, questions: b.blocked.questions };
326
- if (run.timedOut)
327
- return { outcome: "blocked", summary, blockReason: "incomplete", questions: [`Stopped at the ${e.limits.maxMinutes}-minute limit before the form was ready for review.`] };
379
+ if (run.timedOut) {
380
+ return { outcome: "blocked", summary, blockReason: "incomplete", questions: [`Stopped at the ${e.limits.maxMinutes}-minute limit before the form was ready for review.`], diagnostic: this.diagnosticOf(run, code, "timed_out") };
381
+ }
382
+ // #975: a CLI that never called the bridge did NOTHING on the page, which is a different
383
+ // failure from stopping partway — different cause, different fix — and saying `incomplete`
384
+ // for both left the owner with a 51-second run they could not explain. The diagnostic says
385
+ // how long it ran, how many bridge calls it made (0) and what the runner saw it print.
386
+ const calls = b?.actions ?? 0;
387
+ if (calls === 0) {
388
+ const diagnostic = this.diagnosticOf(run, code, !run.output.join("").trim() ? "no_engine_output" : code !== 0 ? "engine_exited_nonzero" : "bridge_unused");
389
+ return { outcome: "blocked", summary, blockReason: "bridge_unused", questions: [bridgeUnusedHelp(e.engine, diagnostic)], diagnostic };
390
+ }
328
391
  const tail = code !== 0 ? ` (the ${e.engine} CLI exited with code ${code})` : "";
329
- return { outcome: "blocked", summary, blockReason: "incomplete", questions: [`The CLI stopped before marking the form ready for review${tail}. Review the form yourself, or retry.`] };
392
+ return {
393
+ outcome: "blocked",
394
+ summary,
395
+ blockReason: "incomplete",
396
+ questions: [`The CLI stopped before marking the form ready for review${tail}. Review the form yourself, or retry.`],
397
+ diagnostic: this.diagnosticOf(run, code, code !== 0 ? "engine_exited_nonzero" : "bridge_unused"),
398
+ };
330
399
  }
331
400
  activeMs(run) {
332
401
  return run.activeMs + (run.state === "running" ? this.now() - run.activeSince : 0);
@@ -524,6 +593,9 @@ export class LocalApplyRuntime {
524
593
  ...(r.outcome === "submitted" && r.submitted ? { submitted: r.submitted } : {}),
525
594
  ...(r.outcome === "blocked" ? { blockReason: r.blockReason ?? "incomplete", questions: (r.questions ?? []).map((q) => redactText(q, run.secrets)), ...(r.blockReason === "job_unavailable" && r.unavailable ? { unavailable: r.unavailable } : {}) } : {}),
526
595
  ...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
596
+ // #975 — carried through as the closed vocabulary it is. Nothing here is redacted because
597
+ // nothing here is text: counts, an exit code, and signal ids this platform defines.
598
+ ...(r.diagnostic ? { diagnostic: r.diagnostic } : {}),
527
599
  };
528
600
  if (run.child && run.child.exitCode === null)
529
601
  this.kill(run);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.83",
3
+ "version": "0.4.85",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",