humanish 0.48.0 → 0.50.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.
Files changed (74) hide show
  1. package/README.md +65 -6
  2. package/dist/actor-contract.d.ts +14 -0
  3. package/dist/actor-contract.js.map +1 -1
  4. package/dist/computer-use-actor.d.ts +3 -7
  5. package/dist/computer-use.d.ts +4 -7
  6. package/dist/computer-use.js +39 -3
  7. package/dist/computer-use.js.map +1 -1
  8. package/dist/concurrent-shared-world-lab.d.ts +5 -0
  9. package/dist/concurrent-shared-world-lab.js +30 -1
  10. package/dist/concurrent-shared-world-lab.js.map +1 -1
  11. package/dist/cua-actor-lab.d.ts +7 -0
  12. package/dist/cua-actor-lab.js +50 -9
  13. package/dist/cua-actor-lab.js.map +1 -1
  14. package/dist/e2b-terminal-lab.d.ts +7 -0
  15. package/dist/e2b-terminal-lab.js +54 -0
  16. package/dist/e2b-terminal-lab.js.map +1 -1
  17. package/dist/init-templates.js +7 -2
  18. package/dist/init-templates.js.map +1 -1
  19. package/dist/key-resolution.d.ts +67 -0
  20. package/dist/key-resolution.js +373 -0
  21. package/dist/key-resolution.js.map +1 -0
  22. package/dist/lab-engine.d.ts +11 -0
  23. package/dist/lab-engine.js +18 -0
  24. package/dist/lab-engine.js.map +1 -1
  25. package/dist/openai-responses-cu.d.ts +1 -1
  26. package/dist/openai-responses-cu.js +8 -2
  27. package/dist/openai-responses-cu.js.map +1 -1
  28. package/dist/oss-lab.d.ts +3 -0
  29. package/dist/oss-lab.js.map +1 -1
  30. package/dist/oss-meta-lab.d.ts +3 -0
  31. package/dist/oss-meta-lab.js +13 -0
  32. package/dist/oss-meta-lab.js.map +1 -1
  33. package/dist/pricing.d.ts +20 -0
  34. package/dist/pricing.js +109 -17
  35. package/dist/pricing.js.map +1 -1
  36. package/dist/program.d.ts +18 -1
  37. package/dist/program.js +291 -17
  38. package/dist/program.js.map +1 -1
  39. package/dist/run-detail.d.ts +59 -0
  40. package/dist/run-detail.js +108 -0
  41. package/dist/run-detail.js.map +1 -0
  42. package/dist/run-index.d.ts +71 -0
  43. package/dist/run-index.js +206 -0
  44. package/dist/run-index.js.map +1 -0
  45. package/dist/run-paths.js +16 -1
  46. package/dist/run-paths.js.map +1 -1
  47. package/dist/run-projection.d.ts +182 -0
  48. package/dist/run-projection.js +349 -0
  49. package/dist/run-projection.js.map +1 -0
  50. package/dist/run-status.d.ts +139 -0
  51. package/dist/run-status.js +218 -0
  52. package/dist/run-status.js.map +1 -0
  53. package/dist/run.d.ts +15 -0
  54. package/dist/run.js +115 -6
  55. package/dist/run.js.map +1 -1
  56. package/dist/scripted-browser-lab.d.ts +5 -0
  57. package/dist/scripted-browser-lab.js +29 -1
  58. package/dist/scripted-browser-lab.js.map +1 -1
  59. package/dist/shared-world-lab.d.ts +5 -0
  60. package/dist/shared-world-lab.js +29 -1
  61. package/dist/shared-world-lab.js.map +1 -1
  62. package/dist/tui-app.js +402 -0
  63. package/dist/tui-contract.d.ts +73 -0
  64. package/dist/tui-contract.js +32 -0
  65. package/dist/tui-contract.js.map +1 -0
  66. package/dist/tui-launch.d.ts +48 -0
  67. package/dist/tui-launch.js +159 -0
  68. package/dist/tui-launch.js.map +1 -0
  69. package/docs/contracts/run-bundle.md +8 -0
  70. package/docs/contracts/schemas.md +85 -7
  71. package/docs/goals/current.md +16 -7
  72. package/docs/ramp/README.md +1 -1
  73. package/package.json +8 -5
  74. package/skills/humanish/SKILL.md +19 -0
@@ -0,0 +1,139 @@
1
+ import { type PreparedOutputRoot } from "./selected-output-paths.js";
2
+ export declare const RUN_STATUS_SCHEMA = "humanish.run-status.v1";
3
+ /** The file, relative to the run directory. */
4
+ export declare const RUN_STATUS_FILE = "status.json";
5
+ /** How often a live run touches `updatedAt`. */
6
+ export declare const RUN_STATUS_TOUCH_MS = 5000;
7
+ /**
8
+ * A `running` record whose `updatedAt` is older than this is INTERRUPTED, not alive: the process
9
+ * died without finalizing (a dropped SSH, a killed terminal, a crash). Three touch intervals of
10
+ * slack so an ordinary scheduling hiccup or a slow disk never mislabels a healthy run.
11
+ */
12
+ export declare const RUN_STATUS_STALE_MS: number;
13
+ /** Which manifest a run came from, when it came from one. */
14
+ export interface RunLabProvenance {
15
+ /** The lab id as declared in its manifest (`config.id`). */
16
+ id: string;
17
+ /** Repo-relative manifest path, when the run came from a file on disk. */
18
+ path?: string;
19
+ /** `committed` = humanish/labs, `ignored` = a local overlay, `explicit` = a path the operator passed. */
20
+ origin?: "committed" | "ignored" | "explicit";
21
+ }
22
+ export type RunStatusState = "running" | "finished";
23
+ /** The outcome summary a finalized record carries. Derived from the bundle; never authoritative. */
24
+ export interface RunStatusOutcome {
25
+ /** `review.verdict` verbatim. */
26
+ verdict?: string;
27
+ /** True when the run's own envelope reported success. */
28
+ ok?: boolean;
29
+ /** `review.participants` counts, when the run recorded any. */
30
+ participants?: {
31
+ total: number;
32
+ reachedGoal: number;
33
+ reportedFriction?: number;
34
+ };
35
+ /** The run-level estimate, `null` when declared absent (never coerced to 0). */
36
+ estimatedCostUsd?: number | null;
37
+ durationMs?: number;
38
+ }
39
+ export interface RunStatusRecord {
40
+ schema: typeof RUN_STATUS_SCHEMA;
41
+ runId: string;
42
+ state: RunStatusState;
43
+ mode: "dry-run" | "live";
44
+ /** Absent when the run did not come from a lab manifest (a library caller, a bare `run`). */
45
+ lab?: RunLabProvenance;
46
+ /** The pid that owns the run, for local liveness and (later) cancellation. */
47
+ pid: number;
48
+ startedAt: string;
49
+ /** Refreshed on a fixed cadence while the run is alive; the staleness signal. */
50
+ updatedAt: string;
51
+ completedAt?: string;
52
+ outcome?: RunStatusOutcome;
53
+ }
54
+ export interface RunStatusHandle {
55
+ /** Resolves once the initial record has landed on disk. The write itself is fire-and-forget —
56
+ * starting a run must never block on its own index — but a caller that needs the record to
57
+ * exist before proceeding (a test, or a launcher that hands the run id to another process)
58
+ * can await this instead of polling. */
59
+ readonly started: Promise<void>;
60
+ /** Write `updatedAt` now. Called by the internal cadence; exposed for tests and for backends
61
+ * that want to mark a phase boundary. Never throws. */
62
+ touch(): Promise<void>;
63
+ /** Finalize: state `finished`, `completedAt`, and the derived outcome. Stops the cadence.
64
+ * Idempotent — a second call is a no-op, so a backend with several exit paths is safe. */
65
+ finish(outcome?: RunStatusOutcome): Promise<void>;
66
+ /**
67
+ * Stop the cadence WITHOUT claiming an outcome. For a path that is abandoning the run: the record
68
+ * stays `running` and goes stale, which is the honest reading.
69
+ *
70
+ * Resolves when any IN-FLIGHT write has settled, so a caller that is about to delete the run
71
+ * directory can be sure nothing is still writing into it. Clearing the interval alone is not
72
+ * enough — a write started microseconds earlier is still on its way to disk.
73
+ */
74
+ stop(): Promise<void>;
75
+ }
76
+ export interface BeginRunStatusOptions {
77
+ runId: string;
78
+ mode: "dry-run" | "live";
79
+ lab?: RunLabProvenance;
80
+ /** Injectable clock (tests freeze it; the repo's `now()` convention). */
81
+ now?: () => number;
82
+ /** Injectable pid so a test never depends on the real process id. */
83
+ pid?: number;
84
+ /** Cadence override; 0 disables the interval entirely (tests drive `touch()` themselves). */
85
+ touchMs?: number;
86
+ }
87
+ /** A no-op handle, so a caller that cannot write status still has a uniform interface. */
88
+ export declare function inertRunStatus(): RunStatusHandle;
89
+ /**
90
+ * Bind a run's status records to the lifetime of the run itself.
91
+ *
92
+ * WHY THIS IS NOT A `finally` AT EACH BACKEND. A run function does not have one exit — the lab
93
+ * backends have 18 early `return`s between opening the record and finalizing it, every one of them
94
+ * a fail-closed path (bad subject, packing failure, missing key). Relying on each of those to
95
+ * remember the record is the same per-call-site discipline that already failed once on this
96
+ * contract, and the failure is silent: the run is over, the cadence keeps ticking, and the record
97
+ * keeps saying `running` — a listing surface then shows a dead run as alive for as long as the
98
+ * process lives. CI caught it as a deleted run directory racing a still-live writer.
99
+ *
100
+ * So the scope owns the lifetime. Control returning from the run function IS the run ending,
101
+ * whatever path it took, and any record still open at that moment is finalized with NO outcome:
102
+ * the run ended and we have no verdict to report. That is honest and it is different from both
103
+ * neighbours — a backend that finalized properly carries its real outcome, and a process that
104
+ * CRASHED never reaches here at all, leaving a `running` record to go stale and read as
105
+ * `interrupted`, which is exactly what happened.
106
+ */
107
+ export declare function withRunStatusScope<T>(fn: () => Promise<T>): Promise<T>;
108
+ /**
109
+ * Start a run's status record and keep it fresh. Fire-and-forget by design: a status write that
110
+ * fails must never fail the run it describes, so every write swallows its error. The interval is
111
+ * `unref`'d — this file can never be the reason a process stays alive.
112
+ */
113
+ export declare function beginRunStatus(runPaths: PreparedOutputRoot, options: BeginRunStatusOptions): RunStatusHandle;
114
+ /** The three ways a run reads from disk. `interrupted` is a `running` record gone stale. */
115
+ export type RunLiveness = "running" | "interrupted" | "finished";
116
+ /**
117
+ * Classify a status record. Pure, so the TUI, the CLI and tests share one definition of "alive".
118
+ * `nowMs` is passed in rather than read, so a classification is reproducible.
119
+ */
120
+ export declare function classifyRunStatus(record: Pick<RunStatusRecord, "state" | "updatedAt">, nowMs: number, staleMs?: number): RunLiveness;
121
+ /** Shape guard for a record read off disk. Unknown extra fields are tolerated (additive contract). */
122
+ export declare function isRunStatusRecord(value: unknown): value is RunStatusRecord;
123
+ /**
124
+ * The legacy bridge: infer a lab id for a bundle written BEFORE this contract, where the only
125
+ * attribution was the `lab:<id>` convention on persona/scenario source strings. Deliberately
126
+ * conservative — it reads the convention and nothing else, and a `lab:` prefix with an empty
127
+ * remainder is not an id. Ids may contain colons (`oss:meta`), so only the FIRST segment is
128
+ * stripped. Returns undefined when the bundle carries no such marker.
129
+ */
130
+ export declare function inferLegacyLabId(bundle: {
131
+ persona?: {
132
+ source?: string;
133
+ };
134
+ scenario?: {
135
+ source?: string;
136
+ };
137
+ }): string | undefined;
138
+ /** A monotonic elapsed-ms helper for callers that need a duration without trusting wall clocks. */
139
+ export declare function elapsedMsSince(startNs: bigint): number;
@@ -0,0 +1,218 @@
1
+ // Run identity + liveness on disk (#455/#475): one small record per run that says WHICH LAB the
2
+ // run belongs to and WHETHER IT IS STILL ALIVE, written by every backend.
3
+ //
4
+ // Why it exists. Two questions could not be answered from the filesystem before this:
5
+ // 1. "which lab produced this run?" — the bundle carried no lab field; attribution rode a string
6
+ // convention (`persona.source = "lab:<id>"`) that is not universal.
7
+ // 2. "is this run still going?" — the mid-run bundle flush is gated on an interactive-observer
8
+ // callback, so a run launched by an agent (`lab run --json`) or detached wrote nothing at all
9
+ // until it completed. Anything watching the directory could not tell running from abandoned.
10
+ //
11
+ // EVIDENCE VS INDEX (the honesty rule that makes this safe). `run.json` remains the
12
+ // evidence-of-record; this file is a DERIVED INDEX + LIVENESS RECORD. `verify` never gates on it,
13
+ // nothing here is a claim about what a participant did, and when the two disagree `run.json` wins
14
+ // and this file is rebuildable from it. It exists so a reader can list and classify runs without
15
+ // parsing every bundle (a 25-run tree measured 152ms warm that way), and so a live run is
16
+ // recognizable while it is live.
17
+ //
18
+ // PUBLIC-SAFETY. Only public-safe fields: the run id, the lab id/path/origin (author-chosen names,
19
+ // the same strings `humanish lab list` already prints), the mode, a local pid, and timestamps.
20
+ // Deliberately NOT the hostname or any user/path identity — this file sits inside a run directory
21
+ // that an operator may share, so it must carry nothing a share-safety gate would have to strip.
22
+ import { AsyncLocalStorage } from "node:async_hooks";
23
+ import { hrtime } from "node:process";
24
+ import { writeContainedOutputFile } from "./selected-output-paths.js";
25
+ export const RUN_STATUS_SCHEMA = "humanish.run-status.v1";
26
+ /** The file, relative to the run directory. */
27
+ export const RUN_STATUS_FILE = "status.json";
28
+ /** How often a live run touches `updatedAt`. */
29
+ export const RUN_STATUS_TOUCH_MS = 5_000;
30
+ /**
31
+ * A `running` record whose `updatedAt` is older than this is INTERRUPTED, not alive: the process
32
+ * died without finalizing (a dropped SSH, a killed terminal, a crash). Three touch intervals of
33
+ * slack so an ordinary scheduling hiccup or a slow disk never mislabels a healthy run.
34
+ */
35
+ export const RUN_STATUS_STALE_MS = RUN_STATUS_TOUCH_MS * 3;
36
+ /** A no-op handle, so a caller that cannot write status still has a uniform interface. */
37
+ export function inertRunStatus() {
38
+ return { started: Promise.resolve(), touch: async () => { }, finish: async () => { }, stop: async () => { } };
39
+ }
40
+ /**
41
+ * The set of handles opened inside the currently-running run, so the run's own return finalizes
42
+ * them. Scoped rather than global: labs can run concurrently in one process, and each must clean up
43
+ * only what it opened.
44
+ */
45
+ const runStatusScope = new AsyncLocalStorage();
46
+ /**
47
+ * Bind a run's status records to the lifetime of the run itself.
48
+ *
49
+ * WHY THIS IS NOT A `finally` AT EACH BACKEND. A run function does not have one exit — the lab
50
+ * backends have 18 early `return`s between opening the record and finalizing it, every one of them
51
+ * a fail-closed path (bad subject, packing failure, missing key). Relying on each of those to
52
+ * remember the record is the same per-call-site discipline that already failed once on this
53
+ * contract, and the failure is silent: the run is over, the cadence keeps ticking, and the record
54
+ * keeps saying `running` — a listing surface then shows a dead run as alive for as long as the
55
+ * process lives. CI caught it as a deleted run directory racing a still-live writer.
56
+ *
57
+ * So the scope owns the lifetime. Control returning from the run function IS the run ending,
58
+ * whatever path it took, and any record still open at that moment is finalized with NO outcome:
59
+ * the run ended and we have no verdict to report. That is honest and it is different from both
60
+ * neighbours — a backend that finalized properly carries its real outcome, and a process that
61
+ * CRASHED never reaches here at all, leaving a `running` record to go stale and read as
62
+ * `interrupted`, which is exactly what happened.
63
+ */
64
+ export async function withRunStatusScope(fn) {
65
+ const scope = new Set();
66
+ try {
67
+ return await runStatusScope.run(scope, fn);
68
+ }
69
+ finally {
70
+ // `finish` swallows its own write errors and is idempotent, so this can neither throw over the
71
+ // run's own error nor overwrite an outcome a backend already recorded.
72
+ await Promise.all([...scope].map((handle) => handle.finish()));
73
+ }
74
+ }
75
+ /**
76
+ * Start a run's status record and keep it fresh. Fire-and-forget by design: a status write that
77
+ * fails must never fail the run it describes, so every write swallows its error. The interval is
78
+ * `unref`'d — this file can never be the reason a process stays alive.
79
+ */
80
+ export function beginRunStatus(runPaths, options) {
81
+ const now = options.now ?? (() => Date.now());
82
+ const iso = () => new Date(now()).toISOString();
83
+ const startedAt = iso();
84
+ const base = {
85
+ schema: RUN_STATUS_SCHEMA,
86
+ runId: options.runId,
87
+ state: "running",
88
+ mode: options.mode,
89
+ ...(options.lab === undefined ? {} : { lab: options.lab }),
90
+ pid: options.pid ?? process.pid,
91
+ startedAt,
92
+ updatedAt: startedAt
93
+ };
94
+ let finished = false;
95
+ let writing = Promise.resolve();
96
+ const write = (record) => {
97
+ // Serialized: two overlapping atomic writes of the same path would be a coin flip over which
98
+ // record survives, and a `running` record landing after a `finished` one would resurrect it.
99
+ writing = writing
100
+ .then(() => writeContainedOutputFile(runPaths, RUN_STATUS_FILE, `${JSON.stringify(record, null, 2)}\n`, "utf8"))
101
+ .catch(() => {
102
+ // Deliberately swallowed: the index is a convenience, the bundle is the evidence.
103
+ });
104
+ return writing;
105
+ };
106
+ const started = write(base);
107
+ const touchMs = options.touchMs ?? RUN_STATUS_TOUCH_MS;
108
+ let timer;
109
+ if (touchMs > 0) {
110
+ timer = setInterval(() => {
111
+ if (finished)
112
+ return;
113
+ void write({ ...base, updatedAt: iso() });
114
+ }, touchMs);
115
+ timer.unref?.();
116
+ }
117
+ const stop = () => {
118
+ if (timer !== undefined) {
119
+ clearInterval(timer);
120
+ timer = undefined;
121
+ }
122
+ // `writing` is the tail of the serialized write chain, so awaiting it awaits everything queued.
123
+ return writing.catch(() => undefined);
124
+ };
125
+ const scope = runStatusScope.getStore();
126
+ const handle = {
127
+ started,
128
+ async touch() {
129
+ if (finished)
130
+ return;
131
+ await write({ ...base, updatedAt: iso() });
132
+ },
133
+ async finish(outcome) {
134
+ if (finished)
135
+ return;
136
+ finished = true;
137
+ void stop();
138
+ scope?.delete(handle);
139
+ const completedAt = iso();
140
+ await write({
141
+ ...base,
142
+ state: "finished",
143
+ updatedAt: completedAt,
144
+ completedAt,
145
+ ...(outcome === undefined ? {} : { outcome })
146
+ });
147
+ },
148
+ stop() {
149
+ scope?.delete(handle);
150
+ return stop();
151
+ }
152
+ };
153
+ // The enclosing run now owns this record's lifetime; see `withRunStatusScope`. A caller outside a
154
+ // scope (a direct library import) simply gets the old behavior.
155
+ scope?.add(handle);
156
+ return handle;
157
+ }
158
+ /**
159
+ * Classify a status record. Pure, so the TUI, the CLI and tests share one definition of "alive".
160
+ * `nowMs` is passed in rather than read, so a classification is reproducible.
161
+ */
162
+ export function classifyRunStatus(record, nowMs, staleMs = RUN_STATUS_STALE_MS) {
163
+ if (record.state === "finished")
164
+ return "finished";
165
+ const updated = Date.parse(record.updatedAt);
166
+ if (!Number.isFinite(updated))
167
+ return "interrupted";
168
+ return nowMs - updated <= staleMs ? "running" : "interrupted";
169
+ }
170
+ /** Shape guard for a record read off disk. Unknown extra fields are tolerated (additive contract). */
171
+ export function isRunStatusRecord(value) {
172
+ if (value === null || typeof value !== "object")
173
+ return false;
174
+ const record = value;
175
+ if (record.schema !== RUN_STATUS_SCHEMA)
176
+ return false;
177
+ if (typeof record.runId !== "string" || record.runId === "")
178
+ return false;
179
+ if (record.state !== "running" && record.state !== "finished")
180
+ return false;
181
+ if (record.mode !== "dry-run" && record.mode !== "live")
182
+ return false;
183
+ if (typeof record.pid !== "number")
184
+ return false;
185
+ if (typeof record.startedAt !== "string" || typeof record.updatedAt !== "string")
186
+ return false;
187
+ if (record.lab !== undefined) {
188
+ if (record.lab === null || typeof record.lab !== "object")
189
+ return false;
190
+ if (typeof record.lab.id !== "string")
191
+ return false;
192
+ }
193
+ return true;
194
+ }
195
+ /**
196
+ * The legacy bridge: infer a lab id for a bundle written BEFORE this contract, where the only
197
+ * attribution was the `lab:<id>` convention on persona/scenario source strings. Deliberately
198
+ * conservative — it reads the convention and nothing else, and a `lab:` prefix with an empty
199
+ * remainder is not an id. Ids may contain colons (`oss:meta`), so only the FIRST segment is
200
+ * stripped. Returns undefined when the bundle carries no such marker.
201
+ */
202
+ export function inferLegacyLabId(bundle) {
203
+ for (const source of [bundle.persona?.source, bundle.scenario?.source]) {
204
+ if (typeof source !== "string")
205
+ continue;
206
+ if (!source.startsWith("lab:"))
207
+ continue;
208
+ const id = source.slice("lab:".length).trim();
209
+ if (id !== "")
210
+ return id;
211
+ }
212
+ return undefined;
213
+ }
214
+ /** A monotonic elapsed-ms helper for callers that need a duration without trusting wall clocks. */
215
+ export function elapsedMsSince(startNs) {
216
+ return Number((hrtime.bigint() - startNs) / 1000000n);
217
+ }
218
+ //# sourceMappingURL=run-status.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run-status.js","sourceRoot":"","sources":["../src/run-status.ts"],"names":[],"mappings":"AAAA,gGAAgG;AAChG,0EAA0E;AAC1E,EAAE;AACF,sFAAsF;AACtF,mGAAmG;AACnG,yEAAyE;AACzE,iGAAiG;AACjG,mGAAmG;AACnG,kGAAkG;AAClG,EAAE;AACF,oFAAoF;AACpF,kGAAkG;AAClG,kGAAkG;AAClG,iGAAiG;AACjG,0FAA0F;AAC1F,iCAAiC;AACjC,EAAE;AACF,mGAAmG;AACnG,+FAA+F;AAC/F,kGAAkG;AAClG,gGAAgG;AAEhG,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAEtC,OAAO,EAAE,wBAAwB,EAA2B,MAAM,4BAA4B,CAAC;AAE/F,MAAM,CAAC,MAAM,iBAAiB,GAAG,wBAAwB,CAAC;AAE1D,+CAA+C;AAC/C,MAAM,CAAC,MAAM,eAAe,GAAG,aAAa,CAAC;AAE7C,gDAAgD;AAChD,MAAM,CAAC,MAAM,mBAAmB,GAAG,KAAK,CAAC;AAEzC;;;;GAIG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,mBAAmB,GAAG,CAAC,CAAC;AAkF3D,0FAA0F;AAC1F,MAAM,UAAU,cAAc;IAC5B,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,EAAE,KAAK,EAAE,KAAK,IAAI,EAAE,GAAE,CAAC,EAAE,MAAM,EAAE,KAAK,IAAI,EAAE,GAAE,CAAC,EAAE,IAAI,EAAE,KAAK,IAAI,EAAE,GAAE,CAAC,EAAE,CAAC;AAC7G,CAAC;AAED;;;;GAIG;AACH,MAAM,cAAc,GAAG,IAAI,iBAAiB,EAAwB,CAAC;AAErE;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAI,EAAoB;IAC9D,MAAM,KAAK,GAAG,IAAI,GAAG,EAAmB,CAAC;IACzC,IAAI,CAAC;QACH,OAAO,MAAM,cAAc,CAAC,GAAG,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IAC7C,CAAC;YAAS,CAAC;QACT,+FAA+F;QAC/F,uEAAuE;QACvE,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IACjE,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,QAA4B,EAAE,OAA8B;IACzF,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IAC9C,MAAM,GAAG,GAAG,GAAW,EAAE,CAAC,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;IACxD,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC;IACxB,MAAM,IAAI,GAAoB;QAC5B,MAAM,EAAE,iBAAiB;QACzB,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,KAAK,EAAE,SAAS;QAChB,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,GAAG,CAAC,OAAO,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,CAAC;QAC1D,GAAG,EAAE,OAAO,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG;QAC/B,SAAS;QACT,SAAS,EAAE,SAAS;KACrB,CAAC;IAEF,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,IAAI,OAAO,GAAkB,OAAO,CAAC,OAAO,EAAE,CAAC;IAC/C,MAAM,KAAK,GAAG,CAAC,MAAuB,EAAiB,EAAE;QACvD,6FAA6F;QAC7F,6FAA6F;QAC7F,OAAO,GAAG,OAAO;aACd,IAAI,CAAC,GAAG,EAAE,CAAC,wBAAwB,CAAC,QAAQ,EAAE,eAAe,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;aAC/G,KAAK,CAAC,GAAG,EAAE;YACV,kFAAkF;QACpF,CAAC,CAAC,CAAC;QACL,OAAO,OAAO,CAAC;IACjB,CAAC,CAAC;IAEF,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC;IAE5B,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,mBAAmB,CAAC;IACvD,IAAI,KAAiD,CAAC;IACtD,IAAI,OAAO,GAAG,CAAC,EAAE,CAAC;QAChB,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE;YACvB,IAAI,QAAQ;gBAAE,OAAO;YACrB,KAAK,KAAK,CAAC,EAAE,GAAG,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;QAC5C,CAAC,EAAE,OAAO,CAAC,CAAC;QACZ,KAAK,CAAC,KAAK,EAAE,EAAE,CAAC;IAClB,CAAC;IACD,MAAM,IAAI,GAAG,GAAkB,EAAE;QAC/B,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YACxB,aAAa,CAAC,KAAK,CAAC,CAAC;YACrB,KAAK,GAAG,SAAS,CAAC;QACpB,CAAC;QACD,gGAAgG;QAChG,OAAO,OAAO,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;IACxC,CAAC,CAAC;IAEF,MAAM,KAAK,GAAG,cAAc,CAAC,QAAQ,EAAE,CAAC;IACxC,MAAM,MAAM,GAAoB;QAC9B,OAAO;QACP,KAAK,CAAC,KAAK;YACT,IAAI,QAAQ;gBAAE,OAAO;YACrB,MAAM,KAAK,CAAC,EAAE,GAAG,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;QAC7C,CAAC;QACD,KAAK,CAAC,MAAM,CAAC,OAA0B;YACrC,IAAI,QAAQ;gBAAE,OAAO;YACrB,QAAQ,GAAG,IAAI,CAAC;YAChB,KAAK,IAAI,EAAE,CAAC;YACZ,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;YACtB,MAAM,WAAW,GAAG,GAAG,EAAE,CAAC;YAC1B,MAAM,KAAK,CAAC;gBACV,GAAG,IAAI;gBACP,KAAK,EAAE,UAAU;gBACjB,SAAS,EAAE,WAAW;gBACtB,WAAW;gBACX,GAAG,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC;aAC9C,CAAC,CAAC;QACL,CAAC;QACD,IAAI;YACF,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;YACtB,OAAO,IAAI,EAAE,CAAC;QAChB,CAAC;KACF,CAAC;IACF,kGAAkG;IAClG,gEAAgE;IAChE,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACnB,OAAO,MAAM,CAAC;AAChB,CAAC;AAKD;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAC/B,MAAoD,EACpD,KAAa,EACb,UAAkB,mBAAmB;IAErC,IAAI,MAAM,CAAC,KAAK,KAAK,UAAU;QAAE,OAAO,UAAU,CAAC;IACnD,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IAC7C,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC;QAAE,OAAO,aAAa,CAAC;IACpD,OAAO,KAAK,GAAG,OAAO,IAAI,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,aAAa,CAAC;AAChE,CAAC;AAED,sGAAsG;AACtG,MAAM,UAAU,iBAAiB,CAAC,KAAc;IAC9C,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC9D,MAAM,MAAM,GAAG,KAAgC,CAAC;IAChD,IAAI,MAAM,CAAC,MAAM,KAAK,iBAAiB;QAAE,OAAO,KAAK,CAAC;IACtD,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,KAAK,KAAK,EAAE;QAAE,OAAO,KAAK,CAAC;IAC1E,IAAI,MAAM,CAAC,KAAK,KAAK,SAAS,IAAI,MAAM,CAAC,KAAK,KAAK,UAAU;QAAE,OAAO,KAAK,CAAC;IAC5E,IAAI,MAAM,CAAC,IAAI,KAAK,SAAS,IAAI,MAAM,CAAC,IAAI,KAAK,MAAM;QAAE,OAAO,KAAK,CAAC;IACtE,IAAI,OAAO,MAAM,CAAC,GAAG,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IACjD,IAAI,OAAO,MAAM,CAAC,SAAS,KAAK,QAAQ,IAAI,OAAO,MAAM,CAAC,SAAS,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC/F,IAAI,MAAM,CAAC,GAAG,KAAK,SAAS,EAAE,CAAC;QAC7B,IAAI,MAAM,CAAC,GAAG,KAAK,IAAI,IAAI,OAAO,MAAM,CAAC,GAAG,KAAK,QAAQ;YAAE,OAAO,KAAK,CAAC;QACxE,IAAI,OAAQ,MAAM,CAAC,GAA+B,CAAC,EAAE,KAAK,QAAQ;YAAE,OAAO,KAAK,CAAC;IACnF,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAGhC;IACC,KAAK,MAAM,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,EAAE,CAAC;QACvE,IAAI,OAAO,MAAM,KAAK,QAAQ;YAAE,SAAS;QACzC,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC;YAAE,SAAS;QACzC,MAAM,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC;QAC9C,IAAI,EAAE,KAAK,EAAE;YAAE,OAAO,EAAE,CAAC;IAC3B,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,mGAAmG;AACnG,MAAM,UAAU,cAAc,CAAC,OAAe;IAC5C,OAAO,MAAM,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,OAAO,CAAC,GAAG,QAAU,CAAC,CAAC;AAC1D,CAAC"}
package/dist/run.d.ts CHANGED
@@ -4,6 +4,7 @@ import type { TaskFunnel } from "./tasks.js";
4
4
  import { type CapturedGitState } from "./core/git-state.js";
5
5
  import type { E2BDesktopModule } from "./e2b-desktop-launch.js";
6
6
  import { type PreparedRunArtifactPaths } from "./run-paths.js";
7
+ import { type RunLabProvenance } from "./run-status.js";
7
8
  export declare const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v1";
8
9
  export declare const SHARED_WORLD_SCHEMA = "humanish.shared-world.v1";
9
10
  export declare const REVIEW_SCHEMA = "humanish.review.v1";
@@ -13,6 +14,8 @@ export declare const DOCTOR_SCHEMA = "humanish.doctor-result.v1";
13
14
  export declare const CLEANUP_SCHEMA = "humanish.cleanup-result.v1";
14
15
  export declare const PUBLIC_TARGET_CWD = "[target-cwd]";
15
16
  export interface RunOptions {
17
+ /** Which manifest produced this run (#455). */
18
+ lab?: RunLabProvenance;
16
19
  cwd: string;
17
20
  actor?: string;
18
21
  actorCommand?: string[];
@@ -744,6 +747,13 @@ export interface RunBundle {
744
747
  * resource lease. Optional + additive; core never enumerates provider accounts.
745
748
  */
746
749
  providerResources?: RunProviderResource[];
750
+ /**
751
+ * Which lab manifest produced this run (#455). Optional + additive: absent on every bundle
752
+ * written before this contract and on library callers who pass a LabConfig directly (the run is
753
+ * then honestly lab-less rather than guessed). For older bundles a reader may fall back to
754
+ * `inferLegacyLabId`, which reads only the historical `persona.source = "lab:<id>"` convention.
755
+ */
756
+ lab?: RunLabProvenance;
747
757
  /**
748
758
  * OPTIONAL, ADDITIVE run-level cost ESTIMATE (humanish.run-cost-summary.v1): the sum of every
749
759
  * lane's model-token estimate PLUS the E2B desktop-minute estimate, carrying the SAME
@@ -1037,6 +1047,11 @@ export interface DoctorResult {
1037
1047
  message: string;
1038
1048
  }>;
1039
1049
  }
1050
+ /**
1051
+ * The synthetic/local backends. The body runs inside a status scope so that returning from it —
1052
+ * by any of its exits, including the fail-closed ones — finalizes whatever status records it
1053
+ * opened. See `withRunStatusScope`.
1054
+ */
1040
1055
  export declare function runDryRun(options: RunOptions): Promise<RunResult>;
1041
1056
  type LocalActorTerminalStatus = Extract<RunSimulationStatus, "passed" | "failed" | "blocked" | "timed_out">;
1042
1057
  /**
package/dist/run.js CHANGED
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
4
4
  import os from "node:os";
5
+ import { existsSync } from "node:fs";
5
6
  import path from "node:path";
6
7
  import { parse as parseYaml } from "yaml";
7
8
  // The deterministic browser-persona driver lives in the scripted-browser-actor leaf module
@@ -21,6 +22,9 @@ import { parseResolvedPersona, personaToDirectives, renderPersonaPromptSection }
21
22
  import { round6 } from "./pricing.js";
22
23
  import { containsSensitive, digestText, redactText, redactToSecretLabel, tailText } from "./redaction.js";
23
24
  import { bindExistingRunArtifactPaths, RUNS_RELATIVE_ROOT, isSafeRunIdSegment, prepareRunArtifactPaths, resolveExistingRunDirectory, resolveLatestRunDirectory, resolveRunsRoot, validatePreparedRunArtifactPaths } from "./run-paths.js";
25
+ import { probeKeySources } from "./key-resolution.js";
26
+ import { beginRunStatus, withRunStatusScope } from "./run-status.js";
27
+ import { TUI_MIN_NODE_MAJOR, nodeSupportsTui, tuiBundleUrl } from "./tui-contract.js";
24
28
  import { assertPreparedSelectedOutputDirectory, assertSafeOutputPathSegment, bindExistingManagedHumanishOutputDirectory, prepareContainedOutputDirectory, prepareContainedOutputDirectoryRoot, prepareContainedOutputFile, prepareSelectedOutputDirectory, readContainedRegularFile, writeContainedOutputFile, writePreparedRunLatestPointer } from "./selected-output-paths.js";
25
29
  export const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v1";
26
30
  export const SHARED_WORLD_SCHEMA = "humanish.shared-world.v1";
@@ -164,7 +168,15 @@ const builtinScenario = {
164
168
  source: "builtin:first-run-smoke",
165
169
  sourceDigest: "builtin"
166
170
  };
171
+ /**
172
+ * The synthetic/local backends. The body runs inside a status scope so that returning from it —
173
+ * by any of its exits, including the fail-closed ones — finalizes whatever status records it
174
+ * opened. See `withRunStatusScope`.
175
+ */
167
176
  export async function runDryRun(options) {
177
+ return withRunStatusScope(() => runDryRunInScope(options));
178
+ }
179
+ async function runDryRunInScope(options) {
168
180
  const cwd = path.resolve(options.cwd);
169
181
  const cwdError = await validateCwd(cwd);
170
182
  const warnings = [];
@@ -261,6 +273,13 @@ export async function runDryRun(options) {
261
273
  const selection = await loadDryRunSelection(projectRoot, humanishSource);
262
274
  await assertPreparedSelectedOutputDirectory(projectRoot);
263
275
  const runPaths = await prepareRunArtifactPaths(cwd, runId);
276
+ // Identity + liveness on disk (#455): uniform across every route, so a reader classifies any
277
+ // run from one small file instead of parsing bundles.
278
+ const runStatus = beginRunStatus(runPaths, {
279
+ runId,
280
+ mode: options.dryRun ? "dry-run" : "live",
281
+ ...(options.lab === undefined ? {} : { lab: options.lab })
282
+ });
264
283
  const artifactRoot = runPaths.relativeRunRoot;
265
284
  if (humanishSource === "missing") {
266
285
  warnings.push("Committed humanish/ source was not found; using built-in synthetic dry-run defaults.");
@@ -280,6 +299,7 @@ export async function runDryRun(options) {
280
299
  createdAt,
281
300
  cwd,
282
301
  artifactRoot,
302
+ ...(options.lab === undefined ? {} : { lab: options.lab }),
283
303
  source,
284
304
  persona: selection.persona,
285
305
  scenario: selection.scenario,
@@ -322,7 +342,7 @@ export async function runDryRun(options) {
322
342
  review: createReviewSummary(),
323
343
  feedbackCandidates: []
324
344
  };
325
- await writeRunBundleArtifacts(runPaths, bundle);
345
+ await writeRunBundleArtifacts(runPaths, bundle, runStatus);
326
346
  await writePreparedRunLatestPointer(runPaths, `${JSON.stringify({
327
347
  schema: "humanish.latest-run.v1",
328
348
  runId,
@@ -380,6 +400,13 @@ async function runBrowserAppProof(options) {
380
400
  const selection = await loadDryRunSelection(options.projectRoot, humanishSource);
381
401
  await assertPreparedSelectedOutputDirectory(options.projectRoot);
382
402
  const runPaths = await prepareRunArtifactPaths(options.cwd, runId);
403
+ // Identity + liveness on disk (#455): uniform across every route, so a reader classifies any
404
+ // run from one small file instead of parsing bundles.
405
+ const runStatus = beginRunStatus(runPaths, {
406
+ runId,
407
+ mode: options.dryRun ? "dry-run" : "live",
408
+ ...(options.lab === undefined ? {} : { lab: options.lab })
409
+ });
383
410
  const artifactRoot = runPaths.relativeRunRoot;
384
411
  if (selection.browserJourneyFailure) {
385
412
  return {
@@ -425,6 +452,7 @@ async function runBrowserAppProof(options) {
425
452
  createdAt,
426
453
  cwd: options.cwd,
427
454
  artifactRoot,
455
+ ...(options.lab === undefined ? {} : { lab: options.lab }),
428
456
  source,
429
457
  persona: {
430
458
  id: selection.persona.id,
@@ -548,7 +576,7 @@ async function runBrowserAppProof(options) {
548
576
  review,
549
577
  feedbackCandidates: []
550
578
  };
551
- await writeRunBundleArtifacts(runPaths, bundle);
579
+ await writeRunBundleArtifacts(runPaths, bundle, runStatus);
552
580
  await writePreparedRunLatestPointer(runPaths, `${JSON.stringify({
553
581
  schema: "humanish.latest-run.v1",
554
582
  runId,
@@ -701,6 +729,13 @@ async function runLocalCodexTui(options) {
701
729
  const selection = await loadDryRunSelection(options.projectRoot, humanishSource);
702
730
  await assertPreparedSelectedOutputDirectory(options.projectRoot);
703
731
  const runPaths = await prepareRunArtifactPaths(options.cwd, runId);
732
+ // Identity + liveness on disk (#455): uniform across every route, so a reader classifies any
733
+ // run from one small file instead of parsing bundles.
734
+ const runStatus = beginRunStatus(runPaths, {
735
+ runId,
736
+ mode: options.dryRun ? "dry-run" : "live",
737
+ ...(options.lab === undefined ? {} : { lab: options.lab })
738
+ });
704
739
  const artifactRoot = runPaths.relativeRunRoot;
705
740
  if (humanishSource === "missing") {
706
741
  warnings.push("Committed humanish/ source was not found; using built-in synthetic local actor defaults.");
@@ -749,6 +784,7 @@ async function runLocalCodexTui(options) {
749
784
  createdAt,
750
785
  cwd: options.cwd,
751
786
  artifactRoot,
787
+ ...(options.lab === undefined ? {} : { lab: options.lab }),
752
788
  source,
753
789
  persona: selection.persona,
754
790
  scenario: selection.scenario,
@@ -889,6 +925,7 @@ async function runLocalCodexTui(options) {
889
925
  createdAt,
890
926
  cwd: options.cwd,
891
927
  artifactRoot,
928
+ ...(options.lab === undefined ? {} : { lab: options.lab }),
892
929
  source,
893
930
  persona: selection.persona,
894
931
  scenario: selection.scenario,
@@ -976,7 +1013,7 @@ async function runLocalCodexTui(options) {
976
1013
  review: createLocalActorReviewSummary("Codex TUI", status, verdictReason),
977
1014
  feedbackCandidates: []
978
1015
  };
979
- await writeRunBundleArtifacts(runPaths, bundle);
1016
+ await writeRunBundleArtifacts(runPaths, bundle, runStatus);
980
1017
  await writePreparedRunLatestPointer(runPaths, `${JSON.stringify({
981
1018
  schema: "humanish.latest-run.v1",
982
1019
  runId,
@@ -1116,6 +1153,13 @@ async function runLocalCodexExec(options) {
1116
1153
  const selection = await loadDryRunSelection(options.projectRoot, humanishSource);
1117
1154
  await assertPreparedSelectedOutputDirectory(options.projectRoot);
1118
1155
  const runPaths = await prepareRunArtifactPaths(options.cwd, runId);
1156
+ // Identity + liveness on disk (#455): uniform across every route, so a reader classifies any
1157
+ // run from one small file instead of parsing bundles.
1158
+ const runStatus = beginRunStatus(runPaths, {
1159
+ runId,
1160
+ mode: options.dryRun ? "dry-run" : "live",
1161
+ ...(options.lab === undefined ? {} : { lab: options.lab })
1162
+ });
1119
1163
  const artifactRoot = runPaths.relativeRunRoot;
1120
1164
  if (humanishSource === "missing") {
1121
1165
  warnings.push("Committed humanish/ source was not found; using built-in synthetic local actor defaults.");
@@ -1315,7 +1359,7 @@ async function runLocalCodexExec(options) {
1315
1359
  events,
1316
1360
  review: createLocalActorReviewSummary(options.simCount === 1 ? "Codex exec" : "Codex exec fanout", status, verdictReason)
1317
1361
  });
1318
- await writeRunBundleArtifacts(runPaths, bundle);
1362
+ await writeRunBundleArtifacts(runPaths, bundle, runStatus);
1319
1363
  await writePreparedRunLatestPointer(runPaths, `${JSON.stringify({
1320
1364
  schema: "humanish.latest-run.v1",
1321
1365
  runId,
@@ -1469,6 +1513,13 @@ async function runLocalCodexAppServer(options) {
1469
1513
  const selection = await loadDryRunSelection(options.projectRoot, humanishSource);
1470
1514
  await assertPreparedSelectedOutputDirectory(options.projectRoot);
1471
1515
  const runPaths = await prepareRunArtifactPaths(options.cwd, runId);
1516
+ // Identity + liveness on disk (#455): uniform across every route, so a reader classifies any
1517
+ // run from one small file instead of parsing bundles.
1518
+ const runStatus = beginRunStatus(runPaths, {
1519
+ runId,
1520
+ mode: options.dryRun ? "dry-run" : "live",
1521
+ ...(options.lab === undefined ? {} : { lab: options.lab })
1522
+ });
1472
1523
  const artifactRoot = runPaths.relativeRunRoot;
1473
1524
  if (humanishSource === "missing") {
1474
1525
  warnings.push("Committed humanish/ source was not found; using built-in synthetic Codex app-server actor defaults.");
@@ -1646,7 +1697,7 @@ async function runLocalCodexAppServer(options) {
1646
1697
  events,
1647
1698
  review: createLocalActorReviewSummary(options.simCount === 1 ? "Codex app-server" : "Codex app-server fanout", status, verdictReason)
1648
1699
  });
1649
- await writeRunBundleArtifacts(runPaths, bundle);
1700
+ await writeRunBundleArtifacts(runPaths, bundle, runStatus);
1650
1701
  await writePreparedRunLatestPointer(runPaths, `${JSON.stringify({
1651
1702
  schema: "humanish.latest-run.v1",
1652
1703
  runId,
@@ -2979,6 +3030,46 @@ export async function doctor(cwdInput) {
2979
3030
  ? "optional peer @e2b/desktop is installed; live desktop lanes can launch"
2980
3031
  : "optional peer @e2b/desktop is NOT installed — dry runs work, but any live desktop lane will fail closed. Install it with `npm i -D @e2b/desktop`."
2981
3032
  };
3033
+ })(),
3034
+ // The stakeholder surface (#455). Reported as capability, never as a gate: the TUI is optional,
3035
+ // and `doctor` is itself mostly run by agents through a pipe, where a TTY requirement says
3036
+ // nothing about whether the PROJECT is ready. So this row is always ok and its job is to tell a
3037
+ // human the surface exists and whether this machine can host it.
3038
+ (() => {
3039
+ const supported = nodeSupportsTui();
3040
+ const bundlePresent = existsSync(tuiBundleUrl(import.meta.url));
3041
+ return {
3042
+ name: "terminal surface",
3043
+ ok: true,
3044
+ message: !supported
3045
+ ? `\`humanish tui\` needs Node ${TUI_MIN_NODE_MAJOR}+ (this is ${process.version}); every other command works here`
3046
+ : bundlePresent
3047
+ ? "`humanish tui` is available in an interactive terminal"
3048
+ : "`humanish tui` bundle is not built in this checkout — run `pnpm build` (installed packages ship it prebuilt)"
3049
+ };
3050
+ })(),
3051
+ // Provider-key discovery (#436): which source supplies each live-run key, through the same
3052
+ // chain a live command resolves (env/--env-file, project overlay, vendor stores, the
3053
+ // humanish user store). Values never appear; sources and fill commands do.
3054
+ ...await (async () => {
3055
+ const probes = await probeKeySources(["OPENAI_API_KEY", "E2B_API_KEY", "GH_TOKEN"], {
3056
+ cwd,
3057
+ env: process.env
3058
+ });
3059
+ return probes.map((probe) => {
3060
+ const present = probe.source !== null;
3061
+ // GH_TOKEN is needed only for private clone subjects, so its absence is informational.
3062
+ const required = probe.name !== "GH_TOKEN";
3063
+ return {
3064
+ name: `key ${probe.name}`,
3065
+ ok: present || !required,
3066
+ message: present
3067
+ ? `supplied by ${probe.source}`
3068
+ : required
3069
+ ? `missing from every source — ${probe.hint}`
3070
+ : `missing (needed only for private clone subjects) — ${probe.hint}`
3071
+ };
3072
+ });
2982
3073
  })()
2983
3074
  ];
2984
3075
  return {
@@ -3374,12 +3465,30 @@ async function readTextIfExists(filePath) {
3374
3465
  throw error;
3375
3466
  }
3376
3467
  }
3377
- async function writeRunBundleArtifacts(runPaths, bundle) {
3468
+ async function writeRunBundleArtifacts(runPaths, bundle,
3469
+ /** Pass ONLY when this write is the run's final one: the shared writer is also used for
3470
+ * mid-run in-progress snapshots, and finalizing there would declare a live run finished (#455). */
3471
+ finalizeStatus) {
3378
3472
  const publicBundle = {
3379
3473
  ...bundle,
3380
3474
  cwd: PUBLIC_TARGET_CWD
3381
3475
  };
3382
3476
  await writeContainedOutputFile(runPaths, "run.json", `${JSON.stringify(publicBundle, null, 2)}\n`, "utf8");
3477
+ await finalizeStatus?.finish({
3478
+ ...(publicBundle.review?.verdict === undefined ? {} : { verdict: publicBundle.review.verdict }),
3479
+ ...(publicBundle.review?.participants === undefined
3480
+ ? {}
3481
+ : {
3482
+ participants: {
3483
+ total: publicBundle.review.participants.total,
3484
+ reachedGoal: publicBundle.review.participants.reachedGoal,
3485
+ ...(publicBundle.review.participants.reportedFriction === undefined
3486
+ ? {}
3487
+ : { reportedFriction: publicBundle.review.participants.reportedFriction })
3488
+ }
3489
+ }),
3490
+ ...(publicBundle.cost?.estimatedTotalUsd === undefined ? {} : { estimatedCostUsd: publicBundle.cost.estimatedTotalUsd })
3491
+ });
3383
3492
  await writeContainedOutputFile(runPaths, "review.json", `${JSON.stringify(publicBundle.review, null, 2)}\n`, "utf8");
3384
3493
  await writeContainedOutputFile(runPaths, "review.md", renderReviewMarkdown(publicBundle), "utf8");
3385
3494
  await writeContainedOutputFile(runPaths, "events.ndjson", `${publicBundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8");