@pome-sh/cli 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "pome-sh",
3
- "version": "0.8.0",
4
- "git_sha": "a0a02905d653d8527558dd2bd64ad5eaded24d07",
5
- "build_time": "2026-07-24T22:05:00.539Z"
3
+ "version": "0.9.0",
4
+ "git_sha": "2412f1350c8c75dbc179884982228467f4452bf2",
5
+ "build_time": "2026-07-27T16:54:03.861Z"
6
6
  }
@@ -13,7 +13,7 @@ import { dirname } from "node:path";
13
13
  import { postAgentResolver, resolveSeams, } from "./agent-resolver.js";
14
14
  import { resolveCredentials } from "./credentials.js";
15
15
  import { ensurePomeGitignored, readLinkCache, resolveCachedAgentId, writeLinkCache, } from "./link-cache.js";
16
- import { readManifest } from "./project-config.js";
16
+ import { normalizeManifestTwins, readManifest } from "./project-config.js";
17
17
  export async function resolveRunAgentIdentity(input) {
18
18
  const manifestRead = await readManifest(input.startDir);
19
19
  if (!manifestRead) {
@@ -38,13 +38,18 @@ export async function resolveRunAgentIdentity(input) {
38
38
  if (cachedId) {
39
39
  return { ...base, agentId: cachedId };
40
40
  }
41
- // Silent re-resolution — a run never prompts for a near-miss.
41
+ // Silent re-resolution — a run never prompts for a near-miss. Send the
42
+ // manifest's twins so a fork / first `pome run` that auto-creates the agent
43
+ // enables the declared services instead of the server's `github` default
44
+ // (F-926); the server merges additively, so this is safe when the agent
45
+ // already exists.
42
46
  const resolved = await postAgentResolver(creds, {
43
47
  name: agent.name ?? agent.slug,
44
48
  slug: agent.slug,
45
49
  description: agent.description,
46
50
  version: agent.version,
47
51
  framework: agent.framework,
52
+ twins: normalizeManifestTwins(manifestRead.manifest.twins),
48
53
  }, resolveSeams({ stdinIsTTY: false }));
49
54
  if (creds.teamId) {
50
55
  await writeLinkCache(projectDir, { agent_id: resolved.id, team_id: creds.teamId });
@@ -18,6 +18,17 @@ import { compileSeedHosted } from "../task/seed-compiler-hosted.js";
18
18
  import { verifySeedWithTwin } from "../task/seed-verifier.js";
19
19
  import { exitCodeFor } from "../hosted/errors.js";
20
20
  const SIDECAR_META_VERSION = 1;
21
+ /**
22
+ * Sidecars written by hand rather than by the compiler mark themselves with
23
+ * this sentinel. It is a statement of provenance, not a cache state: the seeds
24
+ * that carry it encode adversarial setups (a backdoored PR, a fabricated green
25
+ * CI status, an exfiltration lure) that a recompile would quietly rewrite,
26
+ * changing what the task tests while the run still reports normally. So it
27
+ * outranks even `--force`; to recompile one, delete the sidecar or drop its
28
+ * `_meta` first.
29
+ */
30
+ const HAND_AUTHORED_MARKER = "hand-authored";
31
+ const HAND_AUTHORED_SOURCE_HASH = `sha256:${HAND_AUTHORED_MARKER}`;
21
32
  export async function runCompileSeeds(target, opts) {
22
33
  const files = await resolveTaskFiles(target ?? "tasks");
23
34
  if (files.length === 0) {
@@ -56,15 +67,20 @@ async function compileOne(taskPath, opts) {
56
67
  if (seedText.length === 0) {
57
68
  return { path: taskPath, status: "skipped-no-seed", message: "no ## Seed State section" };
58
69
  }
59
- // Stripe scenarios use a different schema; v1 only supports github. Check
60
- // twin first so Stripe scenarios are silently skipped without surfacing
61
- // misleading "still on inline JSON" warnings.
70
+ // Other twins use a different schema; v1 only emits a flat github seed. Check
71
+ // twin first so those tasks are silently skipped without surfacing misleading
72
+ // "still on inline JSON" warnings.
73
+ //
74
+ // A task naming github *alongside* another twin is seeded from a per-twin
75
+ // envelope (`{ github: {...}, linear: {...} }`), so compiling it would replace
76
+ // the envelope with a github-only seed and drop the other twin's half.
62
77
  const twins = readTwinsFromConfig(markdown);
63
- if (twins.length > 0 && !twins.includes("github")) {
78
+ const githubOnly = twins.length === 1 && twins[0] === "github";
79
+ if (twins.length > 0 && !githubOnly) {
64
80
  return {
65
81
  path: taskPath,
66
82
  status: "skipped-unsupported-twin",
67
- message: `twins=${twins.join(",")} not supported yet`
83
+ message: `twins=${twins.join(",")} only single-twin github tasks are supported yet`
68
84
  };
69
85
  }
70
86
  // Heuristic: if the section is a fenced JSON block, this scenario is still
@@ -79,6 +95,16 @@ async function compileOne(taskPath, opts) {
79
95
  }
80
96
  const sidecarPath = sidecarPathFor(taskPath);
81
97
  const proseHash = hashProse(seedText);
98
+ // Checked ahead of `--force` and ahead of the cache: this is authorship, not
99
+ // staleness. The sentinel can never equal a real sha256, so without this the
100
+ // cache always misses and the hand-authored seed is silently overwritten.
101
+ if (existsSync(sidecarPath) && (await isHandAuthored(sidecarPath))) {
102
+ return {
103
+ path: taskPath,
104
+ status: "skipped-hand-authored",
105
+ message: `hand-authored seed left untouched (${sidecarPath}) — delete it or drop its _meta to recompile`
106
+ };
107
+ }
82
108
  // Cache check only applies to local compile — hosted callers may have
83
109
  // different model versions than the locally-pinned COMPILER_MODEL, and the
84
110
  // cloud has its own edge cache to avoid duplicate work anyway.
@@ -186,6 +212,24 @@ async function readSidecarMeta(path) {
186
212
  return null;
187
213
  }
188
214
  }
215
+ /**
216
+ * Deliberately more lenient than `sidecarMetaSchema`: a hand-edited sidecar may
217
+ * carry only the sentinel and omit `version`/`compiled_at`. Failing that parse
218
+ * would fall through to a recompile — the exact overwrite this guards against —
219
+ * so any `_meta` bearing either sentinel field counts.
220
+ */
221
+ async function isHandAuthored(path) {
222
+ try {
223
+ const raw = await readFile(path, "utf8");
224
+ const meta = JSON.parse(raw)._meta;
225
+ if (!meta)
226
+ return false;
227
+ return meta.model === HAND_AUTHORED_MARKER || meta.source_hash === HAND_AUTHORED_SOURCE_HASH;
228
+ }
229
+ catch {
230
+ return false;
231
+ }
232
+ }
189
233
  async function resolveTaskFiles(target) {
190
234
  const abs = resolve(target);
191
235
  if (!existsSync(abs))
@@ -202,6 +246,8 @@ function statusStamp(status) {
202
246
  return "OK ";
203
247
  case "skipped-cached":
204
248
  return "skip";
249
+ case "skipped-hand-authored":
250
+ return "keep";
205
251
  case "skipped-no-seed":
206
252
  return "skip";
207
253
  case "skipped-unsupported-twin":
@@ -272,7 +272,7 @@ export function createProgram() {
272
272
  .argument("<name>", "Human-readable agent name (e.g. \"triage-bot\")")
273
273
  .option("--api-url <url>", "Control-plane URL", process.env.POME_API_URL ?? DEFAULT_CONTROL_PLANE_URL)
274
274
  .option("--force", "Re-resolve the agent even when .pome/link.json already links one", false)
275
- .option("--twins <list>", "Comma-separated services this agent may exercise (e.g. github,slack). Default: the cloud's default enablement.")
275
+ .option("--twins <list>", "Comma-separated services this agent may exercise (e.g. github,slack), unioned with the manifest's twins. Default: the manifest's twins, else the cloud's default enablement.")
276
276
  .description("Create a cloud agent under the current team and write agent.slug to pome.json")
277
277
  .action(async (name, opts) => {
278
278
  try {
@@ -360,12 +360,22 @@ export function createProgram() {
360
360
  .description("Stop a hosted session (aliased as `kill`)")
361
361
  .argument("<session-id>", "Session id (ses_…)")
362
362
  .option("--api-url <url>", "Control-plane URL", process.env.POME_API_URL ?? DEFAULT_CONTROL_PLANE_URL)
363
+ .option("--discard", "Confirm destroying a session whose run has not been graded (F-983)", false)
363
364
  .action(async (sessionId, opts) => {
364
365
  try {
365
- await runSessionStop({ apiBaseUrl: opts.apiUrl, sessionId });
366
+ await runSessionStop({
367
+ apiBaseUrl: opts.apiUrl,
368
+ sessionId,
369
+ discard: opts.discard === true,
370
+ });
366
371
  }
367
372
  catch (err) {
368
- console.error(friendlyHostedError(err));
373
+ // runSessionStop already prints the full refusal detail for
374
+ // HostedDiscardRefusedError; friendlyHostedError returns "" for it
375
+ // so we don't print a duplicate (or bare blank) line here.
376
+ const friendly = friendlyHostedError(err);
377
+ if (friendly)
378
+ console.error(friendly);
369
379
  process.exitCode = 2;
370
380
  }
371
381
  });
@@ -558,7 +568,7 @@ export function createProgram() {
558
568
  // documented exit codes. Anything else falls through to Commander
559
569
  // (treated like self-host).
560
570
  try {
561
- // FDRS-636 — effective trial count: -n wins, else the scenario
571
+ // FDRS-636 — effective trial count: -n wins, else the task
562
572
  // config's `runs` field (both capped at 20). k>1 takes the
563
573
  // trial-group path; k=1 stays EXACTLY the single-run path
564
574
  // below (no group is ever stamped for it).
@@ -769,7 +779,7 @@ export function createProgram() {
769
779
  .argument("[task]", "Path to the task .md file (only with an events.jsonl target)")
770
780
  .description("Assemble a paste-into-IDE fix prompt (no LLM call, no network). With no args, reads the latest FAILED run set under ./runs: the persisted cloud verdicts (verdict.json) become grouped failure signatures over the raw traces, in one prompt. Point it at a trial run dir to target that set, or use the legacy `<events.jsonl> <task.md>` form for a single trace.")
771
781
  .action(async (target, taskArg) => {
772
- // Legacy 2-arg form: <events.jsonl> <scenario.md> — unchanged
782
+ // Legacy 2-arg form: <events.jsonl> <task.md> — unchanged
773
783
  // (CAPTURE-ONLY, FDRS-657: raw trace + declared criteria, no verdict).
774
784
  if (target !== undefined && target.endsWith(".jsonl")) {
775
785
  if (!taskArg) {
@@ -777,7 +787,7 @@ export function createProgram() {
777
787
  process.exitCode = 5;
778
788
  return;
779
789
  }
780
- const [eventsRaw, scenario] = await Promise.all([
790
+ const [eventsRaw, task] = await Promise.all([
781
791
  readFile(resolve(target), "utf8"),
782
792
  parseTaskFile(resolve(taskArg)),
783
793
  ]);
@@ -786,7 +796,7 @@ export function createProgram() {
786
796
  .map((line) => line.trim())
787
797
  .filter((line) => line.length > 0)
788
798
  .map((line) => JSON.parse(line));
789
- console.log(buildFixPrompt({ events, scenario }));
799
+ console.log(buildFixPrompt({ events, task }));
790
800
  return;
791
801
  }
792
802
  if (taskArg !== undefined) {
@@ -810,14 +820,14 @@ export function createProgram() {
810
820
  return;
811
821
  }
812
822
  const set = discovery.set;
813
- let scenario = null;
823
+ let task = null;
814
824
  try {
815
- scenario = await parseTaskFile(resolve(set.taskPath));
825
+ task = await parseTaskFile(resolve(set.taskPath));
816
826
  }
817
827
  catch {
818
828
  // Task file moved/edited since the run — the prompt degrades to the
819
829
  // verdict-embedded criteria.
820
- scenario = null;
830
+ task = null;
821
831
  }
822
832
  const trials = [];
823
833
  for (const [idx, t] of set.trials.entries()) {
@@ -831,7 +841,7 @@ export function createProgram() {
831
841
  console.log(buildGroupFixPrompt({
832
842
  taskName: set.taskName,
833
843
  groupId: set.groupId,
834
- scenario,
844
+ task,
835
845
  trials,
836
846
  }));
837
847
  });
@@ -27,3 +27,11 @@ export declare function readRequiredManifest(startDir?: string): Promise<Manifes
27
27
  * pretty-printed with a trailing newline; YAML carries the schema pointer as a
28
28
  * language-server comment (the `$schema` key is dropped from the YAML body). */
29
29
  export declare function writeManifest(path: string, format: ManifestFormat, data: Record<string, unknown>): Promise<void>;
30
+ /** Normalize the manifest's `twins` to canonical (trim + lowercase, de-duped)
31
+ * twin ids for the `POST /v1/agents` body. The manifest schema keeps `twins`
32
+ * open (min(1) strings, not checked against MOUNTED_TWINS), so entries are
33
+ * normalized but NOT validated here — the server returns a friendly error for
34
+ * an unknown twin. Returns undefined when nothing survives so the cloud's
35
+ * default enablement still applies. Shared by the register command and the
36
+ * run-path identity resolver (F-926). */
37
+ export declare function normalizeManifestTwins(manifestTwins: readonly string[] | undefined): string[] | undefined;
@@ -114,6 +114,24 @@ function slugErrorMessage(raw, path) {
114
114
  const base = `Invalid agent.slug in ${path}: must match ${SLUG_RE} (lowercase kebab-case, max 64 chars).`;
115
115
  return suggestion.length > 0 ? `${base} Did you mean "${suggestion}"?` : base;
116
116
  }
117
+ /** Normalize the manifest's `twins` to canonical (trim + lowercase, de-duped)
118
+ * twin ids for the `POST /v1/agents` body. The manifest schema keeps `twins`
119
+ * open (min(1) strings, not checked against MOUNTED_TWINS), so entries are
120
+ * normalized but NOT validated here — the server returns a friendly error for
121
+ * an unknown twin. Returns undefined when nothing survives so the cloud's
122
+ * default enablement still applies. Shared by the register command and the
123
+ * run-path identity resolver (F-926). */
124
+ export function normalizeManifestTwins(manifestTwins) {
125
+ if (manifestTwins === undefined)
126
+ return undefined;
127
+ const out = new Set();
128
+ for (const twin of manifestTwins) {
129
+ const norm = twin.trim().toLowerCase();
130
+ if (norm.length > 0)
131
+ out.add(norm);
132
+ }
133
+ return out.size > 0 ? [...out] : undefined;
134
+ }
117
135
  async function fileExists(path) {
118
136
  try {
119
137
  await readFile(path, "utf8");
@@ -14,5 +14,20 @@ interface RegisterAgentOptions extends InteractiveSeams {
14
14
  }
15
15
  /** Normalize a `--twins github,slack` comma list to a validated twin array. */
16
16
  export declare function normalizeRegisterTwins(raw: string | undefined): string[] | undefined;
17
+ /** Effective register twins = the manifest's declared `twins` (the default twin
18
+ * set for runs) unioned with any `--twins` flag additions (F-926). Before this,
19
+ * the CLI sent only the flag, so a manifest like `twins: ["gmail"]` never
20
+ * reached `POST /v1/agents` and the server's `github` default won — the first
21
+ * `pome run` then errored with "Requested twins are not enabled".
22
+ *
23
+ * Union is the correct client shape because the server merges additively (never
24
+ * removes), so there is no "reduce" semantics to preserve; sending more is
25
+ * always safe. Manifest entries are normalized (trim + lowercase) to match the
26
+ * flag path and the canonical lowercase twin ids; they are intentionally NOT
27
+ * validated against MOUNTED_TWINS here (the manifest schema keeps `twins` open,
28
+ * and the server returns a friendly error for an unknown twin). Returns
29
+ * undefined when neither source contributes so the cloud's default enablement
30
+ * still applies. */
31
+ export declare function mergeRegisterTwins(manifestTwins: readonly string[] | undefined, flagTwins: readonly string[] | undefined): string[] | undefined;
17
32
  export declare function runRegisterAgent(opts: RegisterAgentOptions): Promise<void>;
18
33
  export { friendlyHostedError };
@@ -19,7 +19,7 @@ import { postAgentResolver, resolveSeams, } from "./agent-resolver.js";
19
19
  import { resolveCredentials } from "./credentials.js";
20
20
  import { suggestFramework } from "./frameworks.js";
21
21
  import { ensurePomeGitignored, readLinkCache, resolveCachedAgentId, writeLinkCache, } from "./link-cache.js";
22
- import { readRequiredManifest, writeManifest, } from "./project-config.js";
22
+ import { normalizeManifestTwins, readRequiredManifest, writeManifest, } from "./project-config.js";
23
23
  import { friendlyHostedError } from "./session.js";
24
24
  const SCHEMA_URL = "https://pome.sh/schemas/v1/pome.json";
25
25
  /** Normalize a `--twins github,slack` comma list to a validated twin array. */
@@ -39,6 +39,26 @@ export function normalizeRegisterTwins(raw) {
39
39
  }
40
40
  return [...new Set(twins)];
41
41
  }
42
+ /** Effective register twins = the manifest's declared `twins` (the default twin
43
+ * set for runs) unioned with any `--twins` flag additions (F-926). Before this,
44
+ * the CLI sent only the flag, so a manifest like `twins: ["gmail"]` never
45
+ * reached `POST /v1/agents` and the server's `github` default won — the first
46
+ * `pome run` then errored with "Requested twins are not enabled".
47
+ *
48
+ * Union is the correct client shape because the server merges additively (never
49
+ * removes), so there is no "reduce" semantics to preserve; sending more is
50
+ * always safe. Manifest entries are normalized (trim + lowercase) to match the
51
+ * flag path and the canonical lowercase twin ids; they are intentionally NOT
52
+ * validated against MOUNTED_TWINS here (the manifest schema keeps `twins` open,
53
+ * and the server returns a friendly error for an unknown twin). Returns
54
+ * undefined when neither source contributes so the cloud's default enablement
55
+ * still applies. */
56
+ export function mergeRegisterTwins(manifestTwins, flagTwins) {
57
+ const merged = new Set(normalizeManifestTwins(manifestTwins) ?? []);
58
+ for (const twin of flagTwins ?? [])
59
+ merged.add(twin);
60
+ return merged.size > 0 ? [...merged] : undefined;
61
+ }
42
62
  // ── Persist: manifest + link cache + gitignore ──────────────────────────────
43
63
  /** Read the manifest's existing agent block (for round-trip preservation and
44
64
  * did-you-mean input) without forcing schema defaults. */
@@ -141,12 +161,15 @@ export async function runRegisterAgent(opts) {
141
161
  return;
142
162
  }
143
163
  }
164
+ // Send the manifest's declared twins (unioned with any --twins flag) so the
165
+ // cloud's enabled services match the manifest, not the server default (F-926).
166
+ const twins = mergeRegisterTwins(manifestRead.manifest.twins, opts.twins);
144
167
  const agent = await createAndPersistAgent({
145
168
  creds,
146
169
  name: opts.name,
147
170
  manifestRead,
148
171
  projectDir,
149
- twins: opts.twins,
172
+ twins,
150
173
  seams: resolveSeams(opts),
151
174
  });
152
175
  const displayName = stripControlCharacters(agent.display_name);
@@ -156,7 +179,7 @@ export async function runRegisterAgent(opts) {
156
179
  if (agent.enabled_services !== undefined) {
157
180
  console.error(`Enabled services: ${agent.enabled_services.length > 0 ? agent.enabled_services.join(", ") : "(none)"}.`);
158
181
  }
159
- else if (opts.twins && opts.twins.length > 0) {
182
+ else if (twins && twins.length > 0) {
160
183
  console.error("Enabled services: not reported by this pome cloud (older control plane) — twin scoping may not have taken effect.");
161
184
  }
162
185
  // Deep-link the registered agent's dashboard page. `/agents/<slug>` is the
@@ -21,5 +21,15 @@ export declare function runSessionList(opts: {
21
21
  export declare function runSessionStop(opts: {
22
22
  apiBaseUrl: string;
23
23
  sessionId: string;
24
+ /** F-983: confirm destroying a session whose run has not been graded.
25
+ * Off by default — a human-typed destructive command gets the refusal
26
+ * printed instead of silently discarding the evidence. */
27
+ discard?: boolean;
24
28
  }): Promise<void>;
29
+ /** Empty string means "already fully reported — print nothing more". Only
30
+ * `HostedDiscardRefusedError` returns it today: `runSessionStop` above just
31
+ * printed the complete multi-line refusal (session, task, open time, the
32
+ * `--discard` escape hatch), so falling through to `err.message` here would
33
+ * either duplicate that or, when the server omits `error.message`, print a
34
+ * bare blank line. Callers must skip printing when this returns "". */
25
35
  export declare function friendlyHostedError(err: unknown): string;
@@ -4,7 +4,7 @@ import { chmod, mkdir, writeFile } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
5
  import { MOUNTED_TWINS } from "@pome-sh/shared-types";
6
6
  import { createHostedClient, perTwinReturnedByCloud } from "../hosted/client.js";
7
- import { HostedAuthError, HostedOrchError, HostedQuotaError, } from "../hosted/errors.js";
7
+ import { HostedAuthError, HostedDiscardRefusedError, HostedOrchError, HostedQuotaError, } from "../hosted/errors.js";
8
8
  import { resolveRunAgentIdentity } from "./agent-identity.js";
9
9
  import { resolveCredentials } from "./credentials.js";
10
10
  import { DEFAULT_DASHBOARD_URL } from "./defaults.js";
@@ -242,10 +242,36 @@ export async function runSessionStop(opts) {
242
242
  baseUrl: creds.apiBaseUrl,
243
243
  apiKey: creds.apiKey,
244
244
  });
245
- await client.deleteSession(opts.sessionId, false);
245
+ try {
246
+ await client.deleteSession(opts.sessionId, false, {
247
+ discard: opts.discard === true,
248
+ });
249
+ }
250
+ catch (err) {
251
+ if (err instanceof HostedDiscardRefusedError) {
252
+ console.error(`Refused to stop ${err.sessionId}: it is still open (${err.state}), so its ` +
253
+ `run has not been graded — Pome creates the run row at finalize, and ` +
254
+ `stopping now discards it.`);
255
+ if (err.taskName)
256
+ console.error(` Task: ${err.taskName}`);
257
+ console.error(` Open for ${err.openSeconds}s.`);
258
+ console.error(` To keep the run, finalize it instead. To discard it anyway: ` +
259
+ `pome session stop ${err.sessionId} --discard`);
260
+ }
261
+ throw err;
262
+ }
246
263
  console.error(`Stopped session ${opts.sessionId}.`);
247
264
  }
265
+ /** Empty string means "already fully reported — print nothing more". Only
266
+ * `HostedDiscardRefusedError` returns it today: `runSessionStop` above just
267
+ * printed the complete multi-line refusal (session, task, open time, the
268
+ * `--discard` escape hatch), so falling through to `err.message` here would
269
+ * either duplicate that or, when the server omits `error.message`, print a
270
+ * bare blank line. Callers must skip printing when this returns "". */
248
271
  export function friendlyHostedError(err) {
272
+ if (err instanceof HostedDiscardRefusedError) {
273
+ return "";
274
+ }
249
275
  if (err instanceof HostedAuthError) {
250
276
  return `${err.message} · Run \`pome login\` or set a valid POME_API_KEY.`;
251
277
  }
@@ -1,6 +1,6 @@
1
1
  import { type FixPromptContext, type GroupFixPromptContext } from "./prompt.js";
2
2
  /**
3
- * Build the paste-into-IDE fix prompt from the raw trace + the scenario's
3
+ * Build the paste-into-IDE fix prompt from the raw trace + the task's
4
4
  * criteria. PURE + synchronous — no network, no LLM, no local judge.
5
5
  *
6
6
  * The output is a complete prompt: the system instructions (how to write the
@@ -4,11 +4,11 @@
4
4
  // (FDRS-657). CAPTURE-ONLY: no LLM/judge call happens here. The former BYOK
5
5
  // CLI-side judge call (`callJudge`) that generated the handoff was removed;
6
6
  // this now returns the fully-assembled prompt (system instructions + the
7
- // scenario's criteria + the captured trace) for the developer to paste into
7
+ // task's criteria + the captured trace) for the developer to paste into
8
8
  // their own coding assistant.
9
9
  import { FIX_PROMPT_SYSTEM_PROMPT, buildFixUserPrompt, buildGroupFixUserPrompt, } from "./prompt.js";
10
10
  /**
11
- * Build the paste-into-IDE fix prompt from the raw trace + the scenario's
11
+ * Build the paste-into-IDE fix prompt from the raw trace + the task's
12
12
  * criteria. PURE + synchronous — no network, no LLM, no local judge.
13
13
  *
14
14
  * The output is a complete prompt: the system instructions (how to write the
@@ -6,7 +6,7 @@ export declare const FIX_PROMPT_SYSTEM_PROMPT: string;
6
6
  export declare function escapeTagContent(text: string): string;
7
7
  export interface FixPromptContext {
8
8
  events: RecorderEvent[];
9
- scenario: Task;
9
+ task: Task;
10
10
  }
11
11
  export declare function buildFixUserPrompt(ctx: FixPromptContext): string;
12
12
  export interface TrialFixInput {
@@ -22,7 +22,7 @@ export interface GroupFixPromptContext {
22
22
  groupId: string | null;
23
23
  /** Parsed task file when it still resolves. Null degrades the prompt to
24
24
  * the verdict-embedded criteria (the file may have moved since the run). */
25
- scenario: Task | null;
25
+ task: Task | null;
26
26
  /** Completed trials of the run set (verdict.json present), run order. */
27
27
  trials: TrialFixInput[];
28
28
  }
@@ -3,7 +3,7 @@
3
3
  // Assembles the paste-into-IDE fix prompt for a failed run (FDRS-657).
4
4
  //
5
5
  // CAPTURE-ONLY: the OSS CLI does NOT call an LLM here. `pome fix-prompt`
6
- // assembles a self-contained prompt — system instructions + the scenario's
6
+ // assembles a self-contained prompt — system instructions + the task's
7
7
  // criteria + the raw captured trace — and prints it so the developer can paste
8
8
  // it into THEIR own coding assistant (Cursor / Claude Code). The former BYOK
9
9
  // local-judge call that generated the handoff CLI-side was removed under
@@ -87,10 +87,10 @@ function renderCriteria(criteria) {
87
87
  .join("\n");
88
88
  }
89
89
  export function buildFixUserPrompt(ctx) {
90
- const criteria = redactSecrets(renderCriteria(ctx.scenario.criteria));
90
+ const criteria = redactSecrets(renderCriteria(ctx.task.criteria));
91
91
  const trace = renderEvents(ctx.events.map((event) => redactEvent(event)));
92
- const taskTitle = redactSecrets(ctx.scenario.title);
93
- const taskPrompt = redactSecrets(ctx.scenario.prompt);
92
+ const taskTitle = redactSecrets(ctx.task.title);
93
+ const taskPrompt = redactSecrets(ctx.task.prompt);
94
94
  return `## Task
95
95
  ${taskTitle}
96
96
 
@@ -192,15 +192,15 @@ export function buildGroupFixUserPrompt(ctx) {
192
192
  const representative = representativeFailingTrial(ctx.trials);
193
193
  const otherFailing = ctx.trials.filter((t) => !t.verdict.passed && t !== representative);
194
194
  const signatures = redactSecrets(renderGroupedSignatures(ctx.trials));
195
- const criteriaBlock = ctx.scenario
196
- ? redactSecrets(renderCriteria(ctx.scenario.criteria))
195
+ const criteriaBlock = ctx.task
196
+ ? redactSecrets(renderCriteria(ctx.task.criteria))
197
197
  : redactSecrets(renderCriteria((ctx.trials[0]?.verdict.criteria_results ?? []).map((r) => ({
198
198
  type: r.criterion.type,
199
199
  text: r.criterion.text,
200
200
  }))));
201
- const promptBlock = ctx.scenario
202
- ? redactSecrets(ctx.scenario.prompt)
203
- : `(task file not found at ${ctx.trials[0]?.verdict.scenario_path ?? "?"} — criteria above come from the cloud verdicts)`;
201
+ const promptBlock = ctx.task
202
+ ? redactSecrets(ctx.task.prompt)
203
+ : `(task file not found at ${ctx.trials[0]?.verdict.task_path ?? "?"} — criteria above come from the cloud verdicts)`;
204
204
  const sections = [];
205
205
  sections.push(`## Run set (cloud-judged)
206
206
  task ${redactSecrets(ctx.taskName)} · ${ctx.groupId ? `group ${ctx.groupId}` : "single run"} · ${passed} of ${completed} completed trials passed`);
@@ -197,7 +197,13 @@ export interface HostedClient {
197
197
  * for the feature-detection contract (a 404 here means an older control
198
198
  * plane; callers must tolerate it silently). */
199
199
  requestMetaUploadUrl(sessionId: string): Promise<MetaUploadUrlResponse>;
200
- /** @param bestEffort default true — hosted runner swallows network errors on teardown */
201
- deleteSession(sessionId: string, bestEffort?: boolean): Promise<void>;
200
+ /** @param bestEffort default true — hosted runner swallows network errors on teardown
201
+ * @param opts.discard F-983 set true to confirm discarding an ungraded
202
+ * session after catching {@link HostedDiscardRefusedError}. Never implied
203
+ * by `bestEffort`: a refusal is a deliberate server decision, not
204
+ * transport noise. */
205
+ deleteSession(sessionId: string, bestEffort?: boolean, opts?: {
206
+ discard?: boolean;
207
+ }): Promise<void>;
202
208
  }
203
209
  export declare function createHostedClient(config: HostedClientConfig): HostedClient;
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  import { createEvalSessionResponseSchema, createSessionResponseSchema, finalizeResponseSchema, sessionPublicSchema, submitResultResponseSchema, } from "../types/shared.js";
3
3
  import { z } from "zod";
4
- import { HostedAuthError, HostedOrchError, HostedQuotaError, } from "./errors.js";
4
+ import { HostedAuthError, HostedOrchError, HostedQuotaError, HostedDiscardRefusedError, } from "./errors.js";
5
5
  // Multi-twin (M3): provenance marker for `per_twin`. A single-twin OLD cloud
6
6
  // ships NO `per_twin` key; `createSessionResponseSchema` then SYNTHESIZES one
7
7
  // (host-rewrites api.pome.sh→mcp.pome.sh with no `/mcp` suffix). Consumers that
@@ -98,6 +98,46 @@ class RetryableFinalizeStatusError extends HostedOrchError {
98
98
  this.name = "RetryableFinalizeStatusError";
99
99
  }
100
100
  }
101
+ /** Read a 409 body as an F-983 discard refusal. Only ever called for status
102
+ * 409 — a non-409 that happens to echo `details.reason` is a server error,
103
+ * not a refusal, and must keep its own status-based handling. */
104
+ async function readDiscardRefusal(res) {
105
+ let body;
106
+ try {
107
+ body = await res.clone().json();
108
+ }
109
+ catch {
110
+ return { kind: "none" };
111
+ }
112
+ const error = body?.error;
113
+ const details = error?.details;
114
+ if (!details || details.reason !== "ungraded_session")
115
+ return { kind: "none" };
116
+ const discardToken = details.discard_token;
117
+ if (typeof discardToken !== "string" || discardToken.length === 0) {
118
+ return { kind: "unusable" };
119
+ }
120
+ return {
121
+ kind: "refusal",
122
+ refusal: {
123
+ message: typeof error?.message === "string" ? error.message : "",
124
+ sessionId: typeof details.session_id === "string" ? details.session_id : "",
125
+ state: typeof details.state === "string" ? details.state : "open",
126
+ taskName: typeof details.task_name === "string" ? details.task_name : null,
127
+ openSeconds: typeof details.open_seconds === "number" ? details.open_seconds : 0,
128
+ discardToken,
129
+ },
130
+ };
131
+ }
132
+ /** The delete was refused but the refusal is unreplayable. Say plainly that
133
+ * the session is still running: a best-effort caller would otherwise read
134
+ * silence as success. NOT HostedDiscardRefusedError — with no token to
135
+ * replay, that type would be a lie. */
136
+ function unusableRefusalError(sessionId) {
137
+ return new HostedOrchError(`DELETE /v1/sessions/${sessionId} → 409: the server refused to delete this ` +
138
+ `session but sent no usable discard_token, so the session was NOT ` +
139
+ `stopped. Retry, or stop it from the dashboard.`, undefined, 409);
140
+ }
101
141
  export function createHostedClient(config) {
102
142
  const timeoutMs = config.timeoutMs ?? 30_000;
103
143
  const finalizeTimeoutMs = config.finalizeTimeoutMs ?? 5 * 60_000;
@@ -590,33 +630,75 @@ export function createHostedClient(config) {
590
630
  state_final_json_b64: Buffer.from(input.stateFinalJson, "utf8").toString("base64"),
591
631
  }, (raw) => submitResultResponseSchema.parse(raw));
592
632
  },
593
- async deleteSession(sessionId, bestEffort = true) {
594
- const ctrl = new AbortController();
595
- const timer = setTimeout(() => ctrl.abort(), timeoutMs);
596
- let res;
597
- try {
598
- res = await fetch(`${config.baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}`, {
599
- method: "DELETE",
600
- // authHeaders, not a hardcoded x-api-key: demo teardown carries a
601
- // bearer demo_token (FDRS-643 live-run finding — the hardcoded
602
- // header made every demo DELETE an opaque 404, silently swallowed
603
- // by best-effort).
604
- headers: authHeaders,
605
- signal: ctrl.signal,
606
- });
607
- }
608
- catch (err) {
609
- if (bestEffort)
633
+ async deleteSession(sessionId, bestEffort = true, opts = {}) {
634
+ // One DELETE attempt, including the read of a 409 body. `confirmDiscard`
635
+ // replays the token the control plane hands back with an F-983 refusal.
636
+ const attempt = async (confirmDiscard) => {
637
+ const ctrl = new AbortController();
638
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
639
+ const qs = confirmDiscard
640
+ ? `?confirm_discard=${encodeURIComponent(confirmDiscard)}`
641
+ : "";
642
+ try {
643
+ const res = await fetch(`${config.baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}${qs}`, {
644
+ method: "DELETE",
645
+ // authHeaders, not a hardcoded x-api-key: demo teardown carries a
646
+ // bearer demo_token (FDRS-643 live-run finding — the hardcoded
647
+ // header made every demo DELETE an opaque 404, silently swallowed
648
+ // by best-effort).
649
+ headers: authHeaders,
650
+ signal: ctrl.signal,
651
+ });
652
+ // Keep the timeout active while consuming a 409 body — fetch()
653
+ // resolves after headers, before the body finishes (same hazard,
654
+ // same fix, as postJson's readResponse above).
655
+ const read = res.status === 409
656
+ ? await readDiscardRefusal(res)
657
+ : { kind: "none" };
658
+ return { res, read };
659
+ }
660
+ catch (err) {
661
+ if (bestEffort)
662
+ return null;
663
+ throw new HostedOrchError(err instanceof Error ? err.message : "network error");
664
+ }
665
+ finally {
666
+ clearTimeout(timer);
667
+ }
668
+ };
669
+ let first = await attempt();
670
+ if (!first)
671
+ return; // bestEffort transport failure
672
+ let res = first.res;
673
+ // F-983: a 409 carrying reason=ungraded_session is a REFUSAL, not the
674
+ // already-closed race. Never fold it into the idempotent-success branch
675
+ // below — that swallow is exactly what made the CLI lie about stopping.
676
+ // Scoped to 409: a 5xx that echoes the same `details` is a server
677
+ // error and keeps its status-based handling.
678
+ if (first.read.kind === "unusable")
679
+ throw unusableRefusalError(sessionId);
680
+ if (first.read.kind === "refusal") {
681
+ const { refusal } = first.read;
682
+ if (!opts.discard) {
683
+ throw new HostedDiscardRefusedError(refusal.message, refusal.sessionId || sessionId, refusal.state, refusal.taskName, refusal.openSeconds, refusal.discardToken);
684
+ }
685
+ const confirmed = await attempt(refusal.discardToken);
686
+ if (!confirmed)
610
687
  return;
611
- throw new HostedOrchError(err instanceof Error ? err.message : "network error");
612
- }
613
- finally {
614
- clearTimeout(timer);
688
+ res = confirmed.res;
689
+ // No third attempt either way: the token was already spent.
690
+ if (confirmed.read.kind === "unusable") {
691
+ throw unusableRefusalError(sessionId);
692
+ }
693
+ if (confirmed.read.kind === "refusal") {
694
+ const stillRefused = confirmed.read.refusal;
695
+ throw new HostedDiscardRefusedError(stillRefused.message, stillRefused.sessionId || sessionId, stillRefused.state, stillRefused.taskName, stillRefused.openSeconds, stillRefused.discardToken);
696
+ }
615
697
  }
616
698
  // Cloud spec (pome-cloud docs/05-api-spec.md): 204 on success. Accept
617
- // 200 too in case a future control-plane returns a body. 409 keeps
618
- // best-effort teardown idempotent when a concurrent reaper already
619
- // closed the row.
699
+ // 200 too in case a future control-plane returns a body. A 409 that is
700
+ // NOT an F-983 refusal keeps best-effort teardown idempotent when a
701
+ // concurrent reaper already closed the row.
620
702
  if (res.status === 204 || res.status === 200 || res.status === 409)
621
703
  return;
622
704
  if (res.status === 404) {
@@ -627,7 +709,7 @@ export function createHostedClient(config) {
627
709
  if (res.status === 401 || res.status === 403) {
628
710
  throw new HostedAuthError(`DELETE /v1/sessions/${sessionId} → ${res.status}`);
629
711
  }
630
- // 404 / 5xx — log via thrown but caller can swallow if mid-teardown.
712
+ // 5xx — thrown, but caller can swallow if mid-teardown.
631
713
  throw new HostedOrchError(`DELETE /v1/sessions/${sessionId} → ${res.status}`);
632
714
  },
633
715
  };
@@ -43,4 +43,18 @@ export declare class HostedTrialError extends Error {
43
43
  readonly errorCode: string;
44
44
  constructor(message: string, errorCode: string);
45
45
  }
46
+ /** F-983 — `DELETE /v1/sessions/:id` refused to destroy a session whose run
47
+ * has not been graded. Pome creates the `runs` row at finalize, so an open
48
+ * session holds an ungraded run and deleting it discards the evidence. The
49
+ * control plane issues `discardToken`; replaying it confirms the discard.
50
+ * Deliberately NOT swallowed by `bestEffort` — that flag covers transport
51
+ * noise and already-closed races, not a deliberate server refusal. */
52
+ export declare class HostedDiscardRefusedError extends Error {
53
+ readonly sessionId: string;
54
+ readonly state: string;
55
+ readonly taskName: string | null;
56
+ readonly openSeconds: number;
57
+ readonly discardToken: string;
58
+ constructor(message: string, sessionId: string, state: string, taskName: string | null, openSeconds: number, discardToken: string);
59
+ }
46
60
  export declare function exitCodeFor(err: unknown): number;
@@ -87,6 +87,28 @@ export class HostedTrialError extends Error {
87
87
  this.name = "HostedTrialError";
88
88
  }
89
89
  }
90
+ /** F-983 — `DELETE /v1/sessions/:id` refused to destroy a session whose run
91
+ * has not been graded. Pome creates the `runs` row at finalize, so an open
92
+ * session holds an ungraded run and deleting it discards the evidence. The
93
+ * control plane issues `discardToken`; replaying it confirms the discard.
94
+ * Deliberately NOT swallowed by `bestEffort` — that flag covers transport
95
+ * noise and already-closed races, not a deliberate server refusal. */
96
+ export class HostedDiscardRefusedError extends Error {
97
+ sessionId;
98
+ state;
99
+ taskName;
100
+ openSeconds;
101
+ discardToken;
102
+ constructor(message, sessionId, state, taskName, openSeconds, discardToken) {
103
+ super(message);
104
+ this.sessionId = sessionId;
105
+ this.state = state;
106
+ this.taskName = taskName;
107
+ this.openSeconds = openSeconds;
108
+ this.discardToken = discardToken;
109
+ this.name = "HostedDiscardRefusedError";
110
+ }
111
+ }
90
112
  export function exitCodeFor(err) {
91
113
  if (err instanceof HostedAuthError)
92
114
  return 3;
@@ -6,9 +6,9 @@ export interface VerdictArtifact {
6
6
  /** Provenance: the only writer is the /finalize response path. */
7
7
  source: "cloud-finalize";
8
8
  task_name: string;
9
- /** The scenario path as the run invocation saw it (may move later —
10
- * readers must tolerate a dangling path). */
11
- scenario_path: string;
9
+ /** The task path as the run invocation saw it (may move later — readers
10
+ * must tolerate a dangling path). */
11
+ task_path: string;
12
12
  /** Shared trial-group id (`grp_` + nanoid21); null on single runs. */
13
13
  group_id: string | null;
14
14
  session_id: string;
@@ -37,7 +37,7 @@ export interface RunSet {
37
37
  /** null = a single run that never had a group. */
38
38
  groupId: string | null;
39
39
  taskName: string;
40
- /** The scenario path recorded at run time (first trial's). */
40
+ /** The task path recorded at run time (first trial's). */
41
41
  taskPath: string;
42
42
  /** Trials sorted by finalized_at ascending. */
43
43
  trials: TrialVerdict[];
@@ -44,7 +44,7 @@ function isVerdictArtifact(parsed) {
44
44
  return false;
45
45
  if (typeof v.task_name !== "string")
46
46
  return false;
47
- if (typeof v.scenario_path !== "string")
47
+ if (typeof v.task_path !== "string" && typeof v.scenario_path !== "string")
48
48
  return false;
49
49
  if (v.group_id !== null && typeof v.group_id !== "string")
50
50
  return false;
@@ -69,6 +69,13 @@ function isVerdictArtifact(parsed) {
69
69
  typeof result.skipped === "boolean");
70
70
  });
71
71
  }
72
+ /** F-933 — collapse the legacy `scenario_path` onto `task_path` so callers
73
+ * only ever deal with one spelling. Validation ran first, so at least one of
74
+ * the two is a string. */
75
+ function normalizeVerdictArtifact(parsed) {
76
+ const { scenario_path: legacyPath, task_path: taskPath, ...rest } = parsed;
77
+ return { ...rest, task_path: taskPath ?? legacyPath };
78
+ }
72
79
  export async function readVerdictArtifact(runDir) {
73
80
  const path = join(runDir, VERDICT_FILENAME);
74
81
  let raw;
@@ -82,7 +89,7 @@ export async function readVerdictArtifact(runDir) {
82
89
  const parsed = JSON.parse(raw);
83
90
  if (!isVerdictArtifact(parsed))
84
91
  return null;
85
- return { runDir, verdict: parsed };
92
+ return { runDir, verdict: normalizeVerdictArtifact(parsed) };
86
93
  }
87
94
  catch {
88
95
  return null;
@@ -136,7 +143,7 @@ export function groupRunSets(trials) {
136
143
  sets.push({
137
144
  groupId: bucket[0].verdict.group_id,
138
145
  taskName: bucket[0].verdict.task_name,
139
- taskPath: bucket[0].verdict.scenario_path,
146
+ taskPath: bucket[0].verdict.task_path,
140
147
  trials: bucket,
141
148
  latestFinalizedAt: last.verdict.finalized_at,
142
149
  anyFailed: bucket.some((t) => !t.verdict.passed),
@@ -22,7 +22,7 @@ export type RunArtifactCoreInput = {
22
22
  export declare function writeRunArtifactsCore(input: RunArtifactCoreInput): Promise<RunArtifacts>;
23
23
  export declare function readLatestRun(artifactsDir: string): Promise<{
24
24
  run_id: string;
25
- scenario: string;
25
+ task: string;
26
26
  run_dir: string;
27
27
  } | undefined>;
28
28
  export type RunMetaSummary = {
@@ -78,9 +78,16 @@ export async function writeRunArtifactsCore(input) {
78
78
  await writeJson(join(runDir, "state_final.json"), input.stateFinal);
79
79
  await writeFile(join(runDir, "stdout.txt"), redactText(input.stdout));
80
80
  await writeFile(join(runDir, "stderr.log"), redactText(input.stderr));
81
+ // F-933 — `task`, not `scenario`. latest.json is a purely local pointer:
82
+ // every run overwrites it and its only readers are `pome eval` / `pome
83
+ // inspect`, which dereference `run_dir`/`run_id`. Nothing on the wire and
84
+ // nothing in the cloud parses it, so the rename needs no compat window.
85
+ // meta.json's `scenario` key is deliberately NOT renamed here — that one is
86
+ // uploaded to cloud finalize and read back out of older run dirs (see
87
+ // AGENTS.md's sanctioned-survivor list).
81
88
  await writeJson(join(input.artifactsDir, "latest.json"), {
82
89
  run_id: input.runId,
83
- scenario: input.scenario.slug,
90
+ task: input.scenario.slug,
84
91
  run_dir: runDir
85
92
  });
86
93
  return { runId: input.runId, runDir };
@@ -504,7 +504,7 @@ export async function runTaskHosted(options) {
504
504
  version: VERDICT_ARTIFACT_VERSION,
505
505
  source: "cloud-finalize",
506
506
  task_name: scenario.slug,
507
- scenario_path: options.taskPath,
507
+ task_path: options.taskPath,
508
508
  group_id: options.groupId ?? null,
509
509
  session_id: session.session_id,
510
510
  cloud_run_id: finalized.run_id,
@@ -548,7 +548,14 @@ export async function runTaskHosted(options) {
548
548
  finally {
549
549
  // Best-effort teardown. TTL would reap anyway; explicit delete keeps
550
550
  // the dashboard sessions list tidy.
551
- await client.deleteSession(session.session_id).catch(() => undefined);
551
+ // F-983: this teardown deliberately discards. On the success path the
552
+ // session is already `done` (finalize closed it) and the DELETE is a
553
+ // no-op; on the failure path we accept losing an ungraded tape rather
554
+ // than leaving open sessions to linger to TTL and pollute the
555
+ // reliability view. Flagged as known residue in the F-983 spec.
556
+ await client
557
+ .deleteSession(session.session_id, true, { discard: true })
558
+ .catch(() => undefined);
552
559
  await rm(signalsDir, { recursive: true, force: true }).catch(() => undefined);
553
560
  }
554
561
  }
@@ -115,7 +115,10 @@ export async function runTrialGroup(options) {
115
115
  // Roll the half-group back so abandoned mints never linger as open
116
116
  // sessions polluting the reliability view; then let the caller map the
117
117
  // error to the documented exit code.
118
- await Promise.all(sessions.map((s) => client.deleteSession(s.session_id).catch(() => undefined)));
118
+ await Promise.all(
119
+ // F-983: these sessions were minted and never launched, so there is no
120
+ // tape to lose — an explicit discard is honest here.
121
+ sessions.map((s) => client.deleteSession(s.session_id, true, { discard: true }).catch(() => undefined)));
119
122
  throw err;
120
123
  }
121
124
  const concurrency = sessions.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pome-sh/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Digital-twin testing for AI agents — run tasks against resettable local or hosted twins and record tool-call traces for evaluation on pome.sh.",
5
5
  "keywords": [
6
6
  "ai",