@proagentstore/cli 0.4.77 → 0.4.79

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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Local artifact generation — the wire contract between PAGS and the runner (#956, epic #943).
3
+ *
4
+ * The Application Tailor turns an approved job lead (#955) into a tailored résumé and cover letter,
5
+ * written by a Codex or Claude Code CLI signed in on the owner's machine, from source files the
6
+ * owner keeps in a workspace folder (`~/jobs` by default). No repository, no browser, no Workers AI
7
+ * and no provider API key: the CLI runs on the machine's own login or subscription only.
8
+ *
9
+ * Like `local-browser/contract.ts`, this file is kept BYTE-IDENTICAL at
10
+ *
11
+ * workers/api/src/lib/local-artifact/contract.ts
12
+ * packages/browser-runner/src/local-artifact/contract.ts
13
+ *
14
+ * (`contract.test.ts` fails the moment they differ) and imports nothing, so it can be vendored.
15
+ *
16
+ * PULL, not push: the runner holds no API token, so PAGS reads a run's events and result with
17
+ * `status` — when an application is read and from the per-minute cron.
18
+ */
19
+ export const LOCAL_ARTIFACT_TASK_TYPE = "local_artifact.generate";
20
+ export const LOCAL_ARTIFACT_RUN_PATH = "/local-artifact/run";
21
+ export const LOCAL_ARTIFACT_STATUS_PATH = "/local-artifact/status";
22
+ export const LOCAL_ARTIFACT_CANCEL_PATH = "/local-artifact/cancel";
23
+ /** The readiness event a completed tailoring run emits, once, through the connection outbox. */
24
+ export const MATERIALS_READY_EVENT = "job.application.materials_ready";
25
+ export const LOCAL_ARTIFACT_ENGINES = ["claude", "codex"];
26
+ export const LOCAL_ARTIFACT_AUTH_MODES = ["machine", "subscription"];
27
+ export const LOCAL_ARTIFACT_SOURCE_ROLES = ["resume", "profile", "answers"];
28
+ export const REQUIRED_SOURCE_ROLES = ["resume", "profile"];
29
+ export const LOCAL_ARTIFACT_BLOCK_REASONS = [
30
+ "missing_source",
31
+ "malformed_lead",
32
+ "workspace_unavailable",
33
+ "missing_information",
34
+ "uncertain_claim",
35
+ "engine_not_signed_in",
36
+ "api_key_refused",
37
+ ];
38
+ export const LOCAL_ARTIFACT_EVENT_TYPES = [
39
+ "engine.auth_checked",
40
+ "engine.started",
41
+ "engine.ended",
42
+ "source.read",
43
+ "source.missing",
44
+ "claims.checked",
45
+ "artifact.written",
46
+ "run.needs_human",
47
+ "retention.swept",
48
+ "note",
49
+ ];
50
+ export const LOCAL_ARTIFACT_ENGINE_AUTHS = ["machine-login", "subscription", "api-key", "missing_login", "unknown"];
51
+ export const LOCAL_ARTIFACT_CAPS = { questions: 20, questionChars: 300, sources: 6, sourceBytes: 256 * 1024 };
52
+ /** An id used as a folder name: no separators, no dots, nothing a path could be built out of. */
53
+ export const PATH_SEGMENT = /^[A-Za-z0-9_-]{1,100}$/;
54
+ /** A source path inside the workspace: relative, no `..`, no absolute, no NUL, no backslash. */
55
+ export function isWorkspaceRelative(path) {
56
+ if (!path || path.length > 300 || path.startsWith("/") || path.startsWith("~") || /[\0\\]/.test(path))
57
+ return false;
58
+ return path.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..");
59
+ }
60
+ /** A workspace folder: `~/…` with the same segment rules. */
61
+ export function isHomeRelative(path) {
62
+ return typeof path === "string" && path.startsWith("~/") && isWorkspaceRelative(path.slice(2));
63
+ }
64
+ const str = (v, max) => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null);
65
+ const oneOf = (list, v) => (list.includes(v) ? v : null);
66
+ const LEAD_FIELDS = ["company", "location", "url", "source", "posted_date", "match_rationale"];
67
+ const HASH = /^[a-f0-9]{64}$/;
68
+ /**
69
+ * The approved lead, validated — or why it is not one. A malformed lead never reaches a CLI: it
70
+ * pauses the application instead, because a résumé tailored to a half-read lead is a plausible guess.
71
+ */
72
+ export function parseLocalArtifactLead(raw) {
73
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
74
+ return { error: "the lead must be an object" };
75
+ const o = raw;
76
+ const eventId = str(o.eventId, 300);
77
+ if (!eventId)
78
+ return { error: "the lead has no eventId" };
79
+ const sourceInstanceId = str(o.sourceInstanceId, 100);
80
+ if (!sourceInstanceId)
81
+ return { error: "the lead has no sourceInstanceId" };
82
+ const leadId = str(o.leadId, 100);
83
+ if (!leadId || !PATH_SEGMENT.test(leadId))
84
+ return { error: "the lead has no usable leadId" };
85
+ const lifecycleVersion = o.lifecycleVersion;
86
+ if (typeof lifecycleVersion !== "number" || !Number.isInteger(lifecycleVersion) || lifecycleVersion < 1)
87
+ return { error: "the lead has no lifecycleVersion" };
88
+ const leadUrl = str(o.leadUrl, 2000) ?? "";
89
+ if (leadUrl && !/^https?:\/\/[^\s/]+/i.test(leadUrl))
90
+ return { error: "the lead's URL is not an http(s) URL" };
91
+ const requestedAt = str(o.requestedAt, 40);
92
+ if (!requestedAt || Number.isNaN(Date.parse(requestedAt)))
93
+ return { error: "the lead has no requestedAt time" };
94
+ const body = o.lead && typeof o.lead === "object" && !Array.isArray(o.lead) ? o.lead : null;
95
+ const title = body ? str(body.title, 300) : null;
96
+ if (!body || !title)
97
+ return { error: "the lead has no job title" };
98
+ const lead = { title };
99
+ for (const f of LEAD_FIELDS) {
100
+ const v = str(body[f], f === "match_rationale" ? 2000 : 500);
101
+ if (v)
102
+ lead[f] = v;
103
+ }
104
+ return { lead: { eventId, sourceInstanceId, leadId, leadUrl, lifecycleVersion, requestedAt, lead } };
105
+ }
106
+ const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "role", "path", "sha256", "bytes", "kind", "total", "unmatched", "reason", "exitCode", "count", "removed", "status"]);
107
+ /** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
108
+ export function parseLocalArtifactEvent(raw) {
109
+ if (!raw || typeof raw !== "object")
110
+ return null;
111
+ const o = raw;
112
+ const type = oneOf(LOCAL_ARTIFACT_EVENT_TYPES, o.type);
113
+ const at = str(o.at, 40);
114
+ if (!type || !at || Number.isNaN(Date.parse(at)))
115
+ return null;
116
+ const event = { type, at };
117
+ if (o.detail && typeof o.detail === "object" && !Array.isArray(o.detail)) {
118
+ const detail = {};
119
+ for (const [k, v] of Object.entries(o.detail)) {
120
+ if (!DETAIL_KEYS.has(k))
121
+ continue;
122
+ if (typeof v === "string")
123
+ detail[k] = v.slice(0, 300);
124
+ else if (typeof v === "number" || typeof v === "boolean")
125
+ detail[k] = v;
126
+ }
127
+ if (Object.keys(detail).length)
128
+ event.detail = detail;
129
+ }
130
+ return event;
131
+ }
132
+ /** A validated result envelope, or the reason it is not one. */
133
+ export function parseLocalArtifactResult(raw) {
134
+ if (!raw || typeof raw !== "object")
135
+ return { error: "result must be an object" };
136
+ const o = raw;
137
+ const runId = str(o.runId, 100);
138
+ if (!runId)
139
+ return { error: "runId is required" };
140
+ const outcome = o.outcome === "completed" || o.outcome === "needs_human" || o.outcome === "failed" ? o.outcome : null;
141
+ if (!outcome)
142
+ return { error: "outcome must be completed, needs_human or failed" };
143
+ const traceId = str(o.traceId, 100);
144
+ if (!traceId)
145
+ return { error: "traceId is required" };
146
+ const engineAuth = oneOf(LOCAL_ARTIFACT_ENGINE_AUTHS, o.engineAuth);
147
+ if (!engineAuth)
148
+ return { error: `engineAuth must be one of ${LOCAL_ARTIFACT_ENGINE_AUTHS.join(", ")}` };
149
+ if (!Array.isArray(o.artifacts) || !Array.isArray(o.sourceHashes))
150
+ return { error: "artifacts and sourceHashes must be arrays" };
151
+ const artifacts = [];
152
+ for (const [i, a] of o.artifacts.slice(0, 10).entries()) {
153
+ const r = (a && typeof a === "object" ? a : {});
154
+ const kind = r.kind === "resume" || r.kind === "cover_letter" ? r.kind : null;
155
+ const path = str(r.path, 500);
156
+ const sha256 = str(r.sha256, 64);
157
+ if (!kind || !path || !isHomeRelative(path) || !sha256 || !HASH.test(sha256) || typeof r.bytes !== "number")
158
+ return { error: `artifacts[${i}] needs a kind, a ~/ path, a sha256 and bytes` };
159
+ artifacts.push({ kind, path, sha256, bytes: r.bytes });
160
+ }
161
+ const sourceHashes = [];
162
+ for (const [i, s] of o.sourceHashes.slice(0, LOCAL_ARTIFACT_CAPS.sources).entries()) {
163
+ const r = (s && typeof s === "object" ? s : {});
164
+ const role = oneOf(LOCAL_ARTIFACT_SOURCE_ROLES, r.role);
165
+ const path = str(r.path, 500);
166
+ const sha256 = str(r.sha256, 64);
167
+ if (!role || !path || !sha256 || !HASH.test(sha256) || typeof r.bytes !== "number")
168
+ return { error: `sourceHashes[${i}] needs a role, a path, a sha256 and bytes` };
169
+ sourceHashes.push({ role, path, sha256, bytes: r.bytes });
170
+ }
171
+ if (outcome === "completed" && (!artifacts.some((a) => a.kind === "resume") || !artifacts.some((a) => a.kind === "cover_letter"))) {
172
+ return { error: "a completed run must report both a resume and a cover_letter artifact" };
173
+ }
174
+ const out = { runId, outcome, artifacts, sourceHashes, profileVersion: str(o.profileVersion, 100), engineAuth, traceId };
175
+ const generatedAt = str(o.generatedAt, 40);
176
+ if (generatedAt && !Number.isNaN(Date.parse(generatedAt)))
177
+ out.generatedAt = generatedAt;
178
+ if (outcome === "needs_human") {
179
+ out.blockReason = oneOf(LOCAL_ARTIFACT_BLOCK_REASONS, o.blockReason) ?? "missing_information";
180
+ out.questions = (Array.isArray(o.questions) ? o.questions : [])
181
+ .filter((q) => typeof q === "string" && q.trim() !== "")
182
+ .slice(0, LOCAL_ARTIFACT_CAPS.questions)
183
+ .map((q) => q.trim().slice(0, LOCAL_ARTIFACT_CAPS.questionChars));
184
+ }
185
+ if (outcome === "failed")
186
+ out.error = str(o.error, 1000) ?? "The run failed without a reason.";
187
+ return { result: out };
188
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * How a tailoring run launches its CLI, what it asks it for, and how the answer is checked (#956).
3
+ *
4
+ * The CLI gets NO tools. Claude Code runs with `--tools ""` and an empty, strict MCP config; Codex
5
+ * runs `--sandbox read-only` with user config and web search off, in an empty scratch folder. The
6
+ * owner's sources are read by the RUNTIME and handed over in the prompt, and the CLI answers with
7
+ * one JSON object; the runtime — not the CLI — writes the files. So the CLI cannot write anywhere,
8
+ * and every write is confined to the run's own folder by code the runner controls.
9
+ *
10
+ * Subscription-only: both modes remove the per-token provider keys from the inherited environment
11
+ * (an empty overlay value means remove — `mergeEnv`), and `machine` also removes the Claude
12
+ * subscription token so the CLI's own stored login is what runs. The env is then READ BACK
13
+ * (`resolveEngineAuth`) and a run whose engine would still see an API key is refused, not started.
14
+ *
15
+ * Truthfulness is checked, not requested: the CLI must cite, for every claim, a verbatim quote
16
+ * from a named source, and every quote is looked up in that source. Years, emails and phone
17
+ * numbers in the generated text must also appear in the sources. Anything that does not check
18
+ * out pauses the run with `needs_human` — no file is written from an unverified draft.
19
+ */
20
+ import { mergeEnv } from "../coding/engine-env.js";
21
+ import { resolveEngineAuth } from "../coding/engine-auth.js";
22
+ import { finalText, missingLogin } from "../local-browser/engine.js";
23
+ export { finalText, missingLogin };
24
+ const PROVIDER_KEYS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
25
+ const SUBSCRIPTION_TOKEN = "CLAUDE_CODE_OAUTH_TOKEN";
26
+ /** The engine's environment: the machine's, never with a provider API key. */
27
+ export function artifactEngineEnv(authMode, base = process.env) {
28
+ const overlay = {};
29
+ for (const k of PROVIDER_KEYS)
30
+ overlay[k] = "";
31
+ if (authMode === "machine")
32
+ overlay[SUBSCRIPTION_TOKEN] = "";
33
+ return mergeEnv(base, overlay);
34
+ }
35
+ export function observedArtifactAuth(engine, env) {
36
+ return resolveEngineAuth(engine, env);
37
+ }
38
+ export function buildArtifactEngineSpec(input) {
39
+ const env = artifactEngineEnv(input.authMode, input.baseEnv);
40
+ if (input.engine === "claude") {
41
+ return {
42
+ command: "claude",
43
+ args: ["-p", input.prompt, "--output-format", "stream-json", "--verbose", "--tools", "", "--strict-mcp-config", "--permission-mode", "dontAsk"],
44
+ env,
45
+ };
46
+ }
47
+ return {
48
+ command: "codex",
49
+ args: ["exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only", "--ignore-user-config", "-c", 'web_search="disabled"', "-c", "tools.web_search=false", input.prompt],
50
+ env,
51
+ };
52
+ }
53
+ /** The brief. The rules are stated, but the checks in `checkDraft` are what enforce them. */
54
+ export function tailorPrompt(lead, sources) {
55
+ const l = lead.lead;
56
+ const job = [
57
+ `Title: ${l.title}`,
58
+ l.company ? `Company: ${l.company}` : "",
59
+ l.location ? `Location: ${l.location}` : "",
60
+ lead.leadUrl ? `URL: ${lead.leadUrl}` : "",
61
+ l.match_rationale ? `Why it matched: ${l.match_rationale}` : "",
62
+ ]
63
+ .filter(Boolean)
64
+ .join("\n");
65
+ const blocks = sources.map((s) => `<source role="${s.role}">\n${s.text}\n</source>`).join("\n\n");
66
+ return [
67
+ "You tailor a job application for the owner of this machine: a résumé variant and a cover letter for ONE job.",
68
+ "You have no tools. Everything you may use is below. Answer with ONE JSON object and nothing else.",
69
+ "",
70
+ "<job>",
71
+ job,
72
+ "</job>",
73
+ "",
74
+ blocks,
75
+ "",
76
+ "Rules — these are checked mechanically, and a draft that breaks one is discarded:",
77
+ "- Use ONLY facts stated in the sources. Never invent or embellish an employer, title, qualification, date, skill, metric, work authorisation, salary expectation or answer.",
78
+ '- For EVERY factual claim about the owner in either document, add an entry to "claims" with the claim, the source role it comes from, and a short VERBATIM quote copied exactly from that source.',
79
+ "- Every year, email address and phone number you write must appear in the sources.",
80
+ "- Reword and reorder to fit the job; do not add what the sources do not say. Leave out what does not fit.",
81
+ '- If something the application needs is missing or uncertain, do not guess: answer {"status":"needs_human","questions":["…"]} instead.',
82
+ "",
83
+ "Answer shape when you can do it:",
84
+ '{"status":"ready","resume_markdown":"…","cover_letter_markdown":"…","claims":[{"text":"…","source":"resume","quote":"…"}]}',
85
+ ].join("\n");
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;
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ /** Case, whitespace, quote-style and dash-style insensitive — so a faithful quote is never failed on typography. */
102
+ export function normalize(s) {
103
+ return s
104
+ .toLowerCase()
105
+ .replace(/[‘’]/g, "'")
106
+ .replace(/[“”]/g, '"')
107
+ .replace(/[–—]/g, "-")
108
+ .replace(/\s+/g, " ")
109
+ .trim();
110
+ }
111
+ const YEAR = /\b(?:19|20)\d{2}\b/g;
112
+ const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
113
+ /** Nine digits or more, so a year range ("2019 - 2021") is not read as a phone number. */
114
+ const PHONE = /\+?\d[\d\s().-]{7,}\d/g;
115
+ const digits = (s) => s.replace(/\D/g, "");
116
+ /**
117
+ * Check the CLI's draft against the sources. Questions name WHAT to confirm, bounded, and are
118
+ * returned only to the owner's own record — never to a trace.
119
+ */
120
+ export function checkDraft(draft, sources) {
121
+ 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 };
123
+ if (draft.status === "needs_human") {
124
+ 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 };
126
+ }
127
+ const resume = typeof draft.resume_markdown === "string" ? draft.resume_markdown.trim() : "";
128
+ const coverLetter = typeof draft.cover_letter_markdown === "string" ? draft.cover_letter_markdown.trim() : "";
129
+ const claims = Array.isArray(draft.claims) ? draft.claims : [];
130
+ 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 };
132
+ }
133
+ const byRole = new Map(sources.map((s) => [s.role, normalize(s.text)]));
134
+ const all = normalize(sources.map((s) => s.text).join("\n"));
135
+ const questions = [];
136
+ let unmatched = 0;
137
+ const flag = (question) => {
138
+ unmatched++;
139
+ if (questions.length < 20)
140
+ questions.push(question);
141
+ };
142
+ for (const raw of claims) {
143
+ const c = (raw && typeof raw === "object" ? raw : {});
144
+ const quote = typeof c.quote === "string" ? normalize(c.quote) : "";
145
+ const src = typeof c.source === "string" ? byRole.get(c.source) : undefined;
146
+ if (quote.length >= 3 && src?.includes(quote))
147
+ continue;
148
+ const claim = typeof c.text === "string" ? c.text.trim() : "";
149
+ flag(`Confirm or correct: “${claim.slice(0, 200) || "(an unnamed claim)"}” — no matching text was found in your ${typeof c.source === "string" ? c.source : "sources"}.`);
150
+ }
151
+ const text = `${resume}\n${coverLetter}`;
152
+ const sourceDigits = digits(sources.map((s) => s.text).join("\n"));
153
+ for (const y of new Set(text.match(YEAR) ?? []))
154
+ if (!all.includes(y))
155
+ flag(`Confirm the year ${y} — it does not appear in your sources.`);
156
+ for (const e of new Set(text.match(EMAIL) ?? []))
157
+ if (!all.includes(e.toLowerCase()))
158
+ flag("Confirm the email address in the draft — it does not appear in your sources.");
159
+ // Nine digits or more, so a year range ("2019 - 2021") is not read as a phone number.
160
+ for (const p of new Set(text.match(PHONE) ?? []))
161
+ if (digits(p).length >= 9 && !sourceDigits.includes(digits(p)))
162
+ flag("Confirm the phone number in the draft — it does not appear in your sources.");
163
+ if (unmatched)
164
+ return { ok: false, reason: "uncertain_claim", questions, claims: claims.length, unmatched };
165
+ return { ok: true, resume, coverLetter, claims: claims.length };
166
+ }