@proagentstore/cli 0.4.84 → 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"]);
@@ -329,9 +330,24 @@ export class ApplyBridge {
329
330
  const facts = await this.probe(t.ref, t.name);
330
331
  if (!facts)
331
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.");
332
- const commits = (facts.submits && facts.method === "post") || [facts.name, t.name, String(forward.element)].some((n) => n && FALLBACK_COMMIT_RE.test(n));
333
- if (commits)
334
- 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);
335
351
  if (NAV_ROLES.has(t.role) || (t.role === "button" && PAGINATION_NAME.test(t.name.trim()))) {
336
352
  if (this.pages >= this.host.limits.maxPages)
337
353
  return this.refuse("browser_click", "read", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call ready_for_review now.`);
@@ -342,7 +358,7 @@ export class ApplyBridge {
342
358
  }
343
359
  // A native file input opens the file chooser for upload_artifact — a DOM fact, whatever its label.
344
360
  const fileInput = facts.tag === "input" && facts.type === "file";
345
- 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)) {
346
362
  const checkpoint = this.requireSupervisor("browser_click", "fill");
347
363
  if (checkpoint)
348
364
  return checkpoint;
@@ -508,20 +524,76 @@ export class ApplyBridge {
508
524
  return text(`Recorded the runner-verified ${reason} listing notice. Stop now.`);
509
525
  }
510
526
  review(summary) {
511
- this.reviewReady = true;
512
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;
513
541
  this.host.emit({ type: "review.ready", detail: { class: "review", count: this.filled } });
514
542
  return text("Recorded. The application waits for the owner's review; nothing was submitted. Stop now.");
515
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
+ }
516
564
  /** The final Submit. Never under fill_and_review; once, gated and traced, under auto_submit. */
517
- async submit(forward) {
565
+ async submit(forward, why = "terminal_label") {
518
566
  const checkpoint = this.requireSupervisor("browser_click", "submit");
519
567
  if (checkpoint)
520
568
  return checkpoint;
521
569
  if (this.host.mode !== "auto_submit" || !this.host.gateId) {
522
- this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "submit", decision: "refused", reason: "fill_and_review" } });
523
- this.review("Stopped at the final submit: this run fills and waits for review.");
524
- 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.");
525
597
  }
526
598
  const before = await this.inspect();
527
599
  if (!before)
@@ -58,6 +58,53 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
58
58
  "job_unavailable",
59
59
  "bridge_unused",
60
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";
61
108
  export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output"];
62
109
  export const LOCAL_APPLY_SIGNALS = ["approval_policy_blocked", "bridge_tools_missing", "auth_prompt", "no_output", "engine_refused_task"];
63
110
  export const LOCAL_APPLY_UNAVAILABLE_REASONS = ["expired", "unavailable"];
@@ -89,7 +136,11 @@ export const LOCAL_APPLY_CAPS = { questions: 20, questionChars: 300, answers: 20
89
136
  const str = (v, max) => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null);
90
137
  const oneOf = (list, v) => (list.includes(v) ? v : null);
91
138
  const isHttpUrl = (v) => /^https?:\/\/[^\s/]+/i.test(v);
92
- 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"]);
93
144
  /** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
94
145
  export function parseLocalApplyEvent(raw) {
95
146
  if (!raw || typeof raw !== "object")
@@ -208,3 +259,104 @@ export function parseLocalApplyResult(raw) {
208
259
  out.error = str(o.error, 1000) ?? "The run failed without a reason.";
209
260
  return { result: out };
210
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.84",
3
+ "version": "0.4.85",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",