humanish 0.112.0 → 0.114.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.
- package/CHANGELOG.md +41 -0
- package/CONTEXT.md +1 -0
- package/README.md +12 -1
- package/TELEMETRY.md +21 -0
- package/dist/actors/codex/restricted-participant-policy.d.ts +21 -1
- package/dist/actors/codex/restricted-participant-policy.js +20 -2
- package/dist/actors/codex/restricted-participant.d.ts +1 -1
- package/dist/actors/codex/restricted-participant.js +2 -1
- package/dist/actors/computer-use/loop/debrief.d.ts +12 -4
- package/dist/actors/computer-use/loop/debrief.js +57 -20
- package/dist/actors/computer-use/loop/ending.d.ts +5 -2
- package/dist/actors/computer-use/loop/impressions.d.ts +14 -0
- package/dist/actors/computer-use/loop/impressions.js +40 -0
- package/dist/actors/computer-use/loop/session.d.ts +3 -1
- package/dist/actors/computer-use/loop/session.js +2 -0
- package/dist/actors/computer-use/loop/trace.js +2 -0
- package/dist/actors/computer-use/loop/types.d.ts +8 -1
- package/dist/actors/computer-use/loop.d.ts +2 -1
- package/dist/actors/computer-use/loop.js +15 -3
- package/dist/actors/computer-use/openai-conversation.d.ts +9 -4
- package/dist/actors/computer-use/openai-conversation.js +60 -29
- package/dist/actors/computer-use/openai-provider.js +18 -12
- package/dist/actors/contract.d.ts +47 -5
- package/dist/actors/contract.js +13 -0
- package/dist/analysis/admission.d.ts +37 -0
- package/dist/analysis/admission.js +60 -0
- package/dist/analysis/automatic-completion.d.ts +0 -6
- package/dist/analysis/automatic-completion.js +3 -3
- package/dist/analysis/automatic-config.d.ts +10 -2
- package/dist/analysis/automatic-config.js +29 -14
- package/dist/analysis/automatic.js +7 -0
- package/dist/analysis/evidence-sources.d.ts +3 -1
- package/dist/analysis/evidence-sources.js +10 -1
- package/dist/analysis/evidence.js +2 -2
- package/dist/analysis/execute.d.ts +20 -6
- package/dist/analysis/execute.js +61 -28
- package/dist/analysis/image-tokens.d.ts +2 -0
- package/dist/analysis/image-tokens.js +1 -1
- package/dist/analysis/job.d.ts +9 -1
- package/dist/analysis/job.js +11 -2
- package/dist/analysis/service.js +6 -3
- package/dist/cli/commands/analyze.js +7 -2
- package/dist/cli/commands/notes.d.ts +3 -0
- package/dist/cli/commands/notes.js +144 -0
- package/dist/cli/commands/observe.js +1 -1
- package/dist/cli/commands/runs.js +33 -10
- package/dist/cli/commands/setup.js +95 -6
- package/dist/cli/commands/study-format.js +7 -4
- package/dist/cli/commands/study-run.js +2 -1
- package/dist/cli/commands/study.js +1 -1
- package/dist/cli/commands/tui.js +1 -1
- package/dist/cli/doctor.d.ts +2 -0
- package/dist/cli/doctor.js +18 -0
- package/dist/cli/findings.js +4 -1
- package/dist/cli/io.d.ts +3 -0
- package/dist/cli/io.js +11 -12
- package/dist/cli/program.js +37 -1
- package/dist/cli/secret-prompt.d.ts +5 -1
- package/dist/cli/secret-prompt.js +6 -2
- package/dist/cli/update-check-worker.d.ts +1 -0
- package/dist/cli/update-check-worker.js +5 -0
- package/dist/cli/update-check.d.ts +46 -0
- package/dist/cli/update-check.js +203 -0
- package/dist/evidence/encoded-text.d.ts +15 -2
- package/dist/evidence/encoded-text.js +68 -4
- package/dist/evidence/secret-scrub.d.ts +1 -1
- package/dist/evidence/secret-scrub.js +23 -2
- package/dist/feedback/draft.d.ts +19 -0
- package/dist/feedback/draft.js +69 -1
- package/dist/feedback/export-bundle.js +2 -2
- package/dist/feedback/export.js +47 -3
- package/dist/feedback/feedback.js +42 -2
- package/dist/keys/key-resolution.js +2 -0
- package/dist/keys/key-status.d.ts +37 -0
- package/dist/keys/key-status.js +68 -0
- package/dist/observer/artifact.d.ts +5 -0
- package/dist/observer/artifact.js +18 -2
- package/dist/observer/notes-route.d.ts +21 -0
- package/dist/observer/notes-route.js +140 -0
- package/dist/observer/pinned-files.d.ts +8 -1
- package/dist/observer/pinned-files.js +32 -3
- package/dist/observer/render.js +38 -14
- package/dist/observer/run-routes.d.ts +8 -2
- package/dist/observer/run-routes.js +47 -6
- package/dist/observer/serve.d.ts +3 -0
- package/dist/observer/serve.js +28 -3
- package/dist/observer-app.html +7 -6
- package/dist/routes/computer-use/local-vm.js +9 -2
- package/dist/routes/computer-use/participant-desktop.d.ts +6 -0
- package/dist/routes/computer-use/participant-execution.js +9 -1
- package/dist/routes/computer-use/rerun-selection.js +23 -21
- package/dist/routes/computer-use/result.js +3 -2
- package/dist/routes/computer-use/self-report.js +7 -1
- package/dist/routes/computer-use/setup.js +14 -0
- package/dist/routes/computer-use/types.d.ts +4 -1
- package/dist/routes/shared-world/bundle-records.js +8 -7
- package/dist/routes/shared-world/bundle.js +1 -1
- package/dist/run/contained-output.d.ts +7 -0
- package/dist/run/contained-output.js +35 -1
- package/dist/run/detail.js +4 -4
- package/dist/run/note-files.d.ts +73 -0
- package/dist/run/note-files.js +184 -0
- package/dist/run/notes.d.ts +37 -0
- package/dist/run/notes.js +198 -0
- package/dist/run/participant-caption.d.ts +11 -1
- package/dist/run/participant-caption.js +30 -1
- package/dist/run/participant-records.d.ts +3 -1
- package/dist/run/participant-records.js +6 -0
- package/dist/run-study.js +17 -1
- package/dist/study/discover.js +6 -1
- package/dist/study/doctor.d.ts +2 -0
- package/dist/study/doctor.js +46 -8
- package/dist/study/init-templates.js +10 -5
- package/dist/study/plan.js +3 -2
- package/dist/study/preflight-result.d.ts +1 -0
- package/dist/study/preflight-result.js +3 -2
- package/dist/study/preflight.d.ts +2 -0
- package/dist/study/preflight.js +54 -14
- package/dist/study/summary.js +2 -2
- package/dist/study/warnings.d.ts +5 -0
- package/dist/study/warnings.js +79 -2
- package/dist/substrates/local/capacity.d.ts +75 -0
- package/dist/substrates/local/capacity.js +143 -0
- package/dist/substrates/local/firecracker-desktop.d.ts +8 -0
- package/dist/substrates/local/firecracker-desktop.js +34 -3
- package/dist/substrates/local/runtime-host.d.ts +16 -3
- package/dist/substrates/local/runtime-host.js +49 -4
- package/dist/substrates/local/runtime.d.ts +10 -0
- package/dist/substrates/local/runtime.js +44 -5
- package/dist/tui-app.js +58 -58
- package/docs/architecture/actor-contract.md +20 -6
- package/docs/contracts/errors.md +2 -2
- package/docs/contracts/feedback.md +8 -1
- package/docs/contracts/run-bundle.md +57 -0
- package/docs/contracts/schemas.md +62 -36
- package/docs/contracts/study-analysis.md +41 -8
- package/docs/principles/invariants-and-defaults.md +2 -2
- package/docs/product/automatic-analysis.md +22 -16
- package/package.json +2 -2
- package/skills/humanish/SKILL.md +26 -10
|
@@ -204,7 +204,12 @@ function spreadOrder(entries) {
|
|
|
204
204
|
}
|
|
205
205
|
return result;
|
|
206
206
|
}
|
|
207
|
-
|
|
207
|
+
/** The trace messages that carry a participant's closing impressions. */
|
|
208
|
+
export function impressionEventIds(stream) {
|
|
209
|
+
const impressions = stream.actor?.impressions;
|
|
210
|
+
return new Set(impressions?.status === "collected" ? impressions.items.map((item) => item.messageId) : []);
|
|
211
|
+
}
|
|
212
|
+
export function sourceOrder(entries, capturesOnly, impressions = new Set()) {
|
|
208
213
|
const candidates = capturesOnly ? entries.filter((entry) => entry.capturePath !== null) : entries;
|
|
209
214
|
const ordered = new Set();
|
|
210
215
|
const admit = (entry) => {
|
|
@@ -214,6 +219,10 @@ export function sourceOrder(entries, capturesOnly) {
|
|
|
214
219
|
// Actor endings precede appended run bookkeeping when text slots are scarce.
|
|
215
220
|
if (!capturesOnly)
|
|
216
221
|
admit(entries.findLast((entry) => !entry.kind.startsWith("run_event:")));
|
|
222
|
+
// The participant's closing impressions are its only opinions of the product; keep them all.
|
|
223
|
+
for (const entry of entries)
|
|
224
|
+
if (impressions.has(entry.eventId))
|
|
225
|
+
admit(entry);
|
|
217
226
|
admit(candidates.at(-1));
|
|
218
227
|
admit(candidates[0]);
|
|
219
228
|
const afterCapture = [];
|
|
@@ -6,7 +6,7 @@ import { isRecord } from "../run/type-guards.js";
|
|
|
6
6
|
import { RUN_BUNDLE_FILE } from "../run/bundle.js";
|
|
7
7
|
import { ACTION_CAPTURE_VERSION, } from "./types.js";
|
|
8
8
|
import { digestAnalysisInput, hashAnalysisValue, validateAnalysisInputMetadata, } from "./validation.js";
|
|
9
|
-
import { boundedText, fairShares, hasUnmappedCaptures, participantAssignment, participantSource, sourceEntries, sourceOrder, } from "./evidence-sources.js";
|
|
9
|
+
import { boundedText, fairShares, hasUnmappedCaptures, participantAssignment, participantSource, sourceEntries, sourceOrder, impressionEventIds, } from "./evidence-sources.js";
|
|
10
10
|
import { readBoundedFile, readBoundedFileResult } from "../run/evidence-files.js";
|
|
11
11
|
export const EVIDENCE_LIMITS = Object.freeze({
|
|
12
12
|
participants: 16,
|
|
@@ -312,7 +312,7 @@ function recordCaptureOmissions(packings, sel, omissions) {
|
|
|
312
312
|
omissions.add("Some declared captures have no normalized trace reference.");
|
|
313
313
|
for (const source of packing.captures.keys())
|
|
314
314
|
packing.admitted.add(source);
|
|
315
|
-
for (const source of sourceOrder(packing.entries, false)) {
|
|
315
|
+
for (const source of sourceOrder(packing.entries, false, impressionEventIds(packing.stream))) {
|
|
316
316
|
if (packing.admitted.size >= sel.evidenceShares[index])
|
|
317
317
|
break;
|
|
318
318
|
packing.admitted.add(source);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type AnalysisArtifact, type AnalysisConfig, type AnalysisInput } from "./types.js";
|
|
2
2
|
import { type AnalysisFetch, type AnalysisProvider } from "./provider.js";
|
|
3
3
|
import type { RejectedAnalysisOutput } from "./diagnostics.js";
|
|
4
|
-
export declare const ANALYSIS_PROMPT_VERSION = "study-evidence-
|
|
4
|
+
export declare const ANALYSIS_PROMPT_VERSION = "study-evidence-8";
|
|
5
5
|
export declare const isSupportedAnalysisModel: (model: string) => boolean;
|
|
6
6
|
/** The OpenAI output limit when a study omits maxOutputTokens, and the most it may ask for. */
|
|
7
7
|
export declare const DEFAULT_ANALYSIS_MAX_OUTPUT_TOKENS = 16384;
|
|
@@ -9,9 +9,14 @@ export declare const MAX_ANALYSIS_OUTPUT_TOKENS = 32768;
|
|
|
9
9
|
export interface AnalysisAdmission {
|
|
10
10
|
allowed: boolean;
|
|
11
11
|
error: string | null;
|
|
12
|
+
/** The input tokens admission priced. */
|
|
12
13
|
inputTokenAllowance: number | null;
|
|
13
14
|
outputTokenAllowance: number | null;
|
|
15
|
+
/** The expected cost. Admission compares it, with a margin, with maxCostUsd. */
|
|
14
16
|
estimatedCostUsd: number | null;
|
|
17
|
+
/** The cost if the analyst spends its whole output allowance. */
|
|
18
|
+
worstCaseCostUsd: number | null;
|
|
19
|
+
maxCostUsd: number | null;
|
|
15
20
|
ratesAsOf: string | null;
|
|
16
21
|
}
|
|
17
22
|
export interface AnalysisProgress {
|
|
@@ -23,15 +28,24 @@ export interface AnalysisProgress {
|
|
|
23
28
|
}
|
|
24
29
|
/**
|
|
25
30
|
* Local admission estimate, not a provider-enforced billed-spend guarantee. No token-count API
|
|
26
|
-
* call, credential, or network access.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* assumptions.
|
|
31
|
+
* call, credential, or network access. admission.ts holds the cost model: text at a calibrated
|
|
32
|
+
* bytes-per-token ratio, the expected output, and the worst case that spends the whole output
|
|
33
|
+
* allowance. Each high-detail image is priced from its PNG size by the vision guide's patch
|
|
34
|
+
* formula (see image-tokens.ts), or at the 3,000-token ceiling when its size or the model's sizing
|
|
35
|
+
* is unknown. Unknown models/rates fail closed rather than inheriting those assumptions.
|
|
31
36
|
* Known-sensitive decoded text denies admission with analysis_input_sensitive or
|
|
32
37
|
* analysis_question_sensitive. Neither error includes rejected input values.
|
|
33
38
|
*/
|
|
34
39
|
export declare function estimateAnalysisAdmission(input: AnalysisInput, config: AnalysisConfig): AnalysisAdmission;
|
|
40
|
+
/**
|
|
41
|
+
* The expected cost of an analysis for this many participants before any evidence exists: from a
|
|
42
|
+
* packet with no evidence to one at the evidence limits with every capture at the image-token
|
|
43
|
+
* ceiling. Undefined for Codex and for a model without rates.
|
|
44
|
+
*/
|
|
45
|
+
export declare function analysisCostRange(config: AnalysisConfig, participants: number): {
|
|
46
|
+
low: number;
|
|
47
|
+
high: number;
|
|
48
|
+
} | undefined;
|
|
35
49
|
/** Only for an omitted output limit. Preserve the established allowance when
|
|
36
50
|
* more reasoning/report space would refuse a study its declared budget admits.
|
|
37
51
|
* This is one pre-dispatch choice, never a fallback request or a budget increase. */
|
package/dist/analysis/execute.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { validCodexAnalysisConfig } from "./codex-config.js";
|
|
2
|
-
import { highDetailImageTokens } from "./image-tokens.js";
|
|
2
|
+
import { HIGH_DETAIL_IMAGE_TOKEN_CEILING, highDetailImageTokens } from "./image-tokens.js";
|
|
3
|
+
import { estimateAnalysisCost } from "./admission.js";
|
|
3
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
5
|
import { estimateActorCost, MODEL_RATES } from "../run/pricing.js";
|
|
5
6
|
import { containsSensitive } from "../evidence/redaction.js";
|
|
@@ -10,7 +11,7 @@ import { checkAnalysisResult, hashAnalysisValue, analysisResponseSchema, analysi
|
|
|
10
11
|
import { EVIDENCE_LIMITS } from "./evidence.js";
|
|
11
12
|
import { truncatedFrameWarning, unknownNotificationsWarning, } from "../actors/codex/restricted-notifications.js";
|
|
12
13
|
import { protocolAdditionsWarning, protocolIncompatibilityMessage, } from "../actors/codex/protocol-compat.js";
|
|
13
|
-
export const ANALYSIS_PROMPT_VERSION = "study-evidence-
|
|
14
|
+
export const ANALYSIS_PROMPT_VERSION = "study-evidence-8";
|
|
14
15
|
const SUPPORTED_ANALYSIS_MODELS = Object.freeze([
|
|
15
16
|
"gpt-6-astra",
|
|
16
17
|
"gpt-5.5",
|
|
@@ -61,6 +62,8 @@ Write for the person who will act on the report: a designer, product manager or
|
|
|
61
62
|
|
|
62
63
|
Separately, review the supplied captures as an experienced product designer would and report designFindings: problems a designer would notice in what the screens show, whether or not any participant mentioned them. Look at the size of text and controls (too small to read or to hit comfortably at this window size), layout and density (spread too wide or packed too tight for the task), visual hierarchy, legibility and contrast, wording and labels, consistency between screens, feedback after actions, and anything that makes the product look unfinished or untrustworthy. Judge the screens as the participants' personas would meet them: who they are and what they came to do. Each design finding names the screen in plain words, says what a designer notices, why it matters to a person using the product, and one concrete suggestion. Each must cite at least one supplied capture that shows the problem, and seenByStreamIds lists only participants whose cited captures show it. Rate severity by the effect on a person using the product: major when it misleads or blocks, moderate when it slows or confuses, minor when it is polish. Report only what the captures show; no capture, no design finding. Use D1, D2, and so on as IDs. An empty designFindings array is correct when the captures show no design problem worth a designer's time. Write design findings in the same plain register as headlines.
|
|
63
64
|
|
|
65
|
+
Messages that begin "Impression (kind):" are a participant's closing impressions of the product, where kind is unclear, unfinished, untrustworthy, liked, missing or unlike my work. They are that participant's own opinions: use them as participant statements and quote the words after the label. An unlike my work impression says where the screen differs from how the persona does the same task in its own work or life; with a capture of that screen it is a strong design finding, which cites both. Without such a capture, an impression supports only a finding about what the participant said.
|
|
66
|
+
|
|
64
67
|
Order findings by observed task impact, replication among exposed participants, and recovery. Preserve severity and confidence as separate fields. Do not compute a numeric frustration score or universal priority score. Use F1, F2, and so on as local finding IDs. Explain ordering with concrete evidence. Provide a short, testable next check for each finding. Say when recovery was not observed rather than claiming it was impossible. Keep titles and summaries concise and specific. The overall summary must be grounded in participant reviews and observations, including successful outcomes and evidence limits.`;
|
|
65
68
|
function instructions(config) {
|
|
66
69
|
return `${INSTRUCTIONS}\n\nResearcher question (null means use the standard review): ${JSON.stringify(config.question)}`;
|
|
@@ -126,11 +129,11 @@ function inputError(input) {
|
|
|
126
129
|
}
|
|
127
130
|
/**
|
|
128
131
|
* Local admission estimate, not a provider-enforced billed-spend guarantee. No token-count API
|
|
129
|
-
* call, credential, or network access.
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* assumptions.
|
|
132
|
+
* call, credential, or network access. admission.ts holds the cost model: text at a calibrated
|
|
133
|
+
* bytes-per-token ratio, the expected output, and the worst case that spends the whole output
|
|
134
|
+
* allowance. Each high-detail image is priced from its PNG size by the vision guide's patch
|
|
135
|
+
* formula (see image-tokens.ts), or at the 3,000-token ceiling when its size or the model's sizing
|
|
136
|
+
* is unknown. Unknown models/rates fail closed rather than inheriting those assumptions.
|
|
134
137
|
* Known-sensitive decoded text denies admission with analysis_input_sensitive or
|
|
135
138
|
* analysis_question_sensitive. Neither error includes rejected input values.
|
|
136
139
|
*/
|
|
@@ -141,6 +144,8 @@ export function estimateAnalysisAdmission(input, config) {
|
|
|
141
144
|
inputTokenAllowance: 0,
|
|
142
145
|
outputTokenAllowance: 0,
|
|
143
146
|
estimatedCostUsd: null,
|
|
147
|
+
worstCaseCostUsd: null,
|
|
148
|
+
maxCostUsd: null,
|
|
144
149
|
ratesAsOf: null,
|
|
145
150
|
});
|
|
146
151
|
if ((config.provider === "codex"
|
|
@@ -172,6 +177,8 @@ export function estimateAnalysisAdmission(input, config) {
|
|
|
172
177
|
inputTokenAllowance: null,
|
|
173
178
|
outputTokenAllowance: null,
|
|
174
179
|
estimatedCostUsd: null,
|
|
180
|
+
worstCaseCostUsd: null,
|
|
181
|
+
maxCostUsd: null,
|
|
175
182
|
ratesAsOf: null,
|
|
176
183
|
};
|
|
177
184
|
const rate = MODEL_RATES[config.model];
|
|
@@ -182,32 +189,56 @@ export function estimateAnalysisAdmission(input, config) {
|
|
|
182
189
|
!Number.isFinite(rate.outputUsdPerToken) ||
|
|
183
190
|
rate.outputUsdPerToken < 0)
|
|
184
191
|
return denied("analysis_rate_unknown");
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const estimate = inputTokenAllowance * inputRate * (long ? rate.longContext.inputMultiplier : 1) +
|
|
195
|
-
config.maxOutputTokens *
|
|
196
|
-
rate.outputUsdPerToken *
|
|
197
|
-
(long ? rate.longContext.outputMultiplier : 1);
|
|
198
|
-
// Round upward for admission; rounding a small positive boundary down could admit an overrun.
|
|
199
|
-
const estimatedCostUsd = Math.ceil(estimate * 1e6) / 1e6;
|
|
200
|
-
if (!Number.isFinite(estimatedCostUsd))
|
|
192
|
+
const cost = estimateAnalysisCost(rate, {
|
|
193
|
+
textBytes: Buffer.byteLength(instructions(config)) +
|
|
194
|
+
Buffer.byteLength(evidenceText(input)) +
|
|
195
|
+
Buffer.byteLength(JSON.stringify(analysisResultJsonSchema)),
|
|
196
|
+
imageTokens: input.images.reduce((sum, image) => sum + highDetailImageTokens(config.model, image.dataUrl), 0),
|
|
197
|
+
participants: input.participants.length,
|
|
198
|
+
outputAllowance: config.maxOutputTokens,
|
|
199
|
+
});
|
|
200
|
+
if (!Number.isFinite(cost.worstCaseCostUsd))
|
|
201
201
|
return denied("analysis_rate_unknown");
|
|
202
|
+
const allowed = cost.admittedCostUsd <= config.maxCostUsd;
|
|
202
203
|
return {
|
|
203
|
-
allowed
|
|
204
|
-
error:
|
|
205
|
-
inputTokenAllowance,
|
|
204
|
+
allowed,
|
|
205
|
+
error: allowed ? null : "analysis_budget_exceeded",
|
|
206
|
+
inputTokenAllowance: cost.inputTokens,
|
|
206
207
|
outputTokenAllowance: config.maxOutputTokens,
|
|
207
|
-
estimatedCostUsd,
|
|
208
|
+
estimatedCostUsd: cost.expectedCostUsd,
|
|
209
|
+
worstCaseCostUsd: cost.worstCaseCostUsd,
|
|
210
|
+
maxCostUsd: config.maxCostUsd,
|
|
208
211
|
ratesAsOf: rate.asOf,
|
|
209
212
|
};
|
|
210
213
|
}
|
|
214
|
+
/** Structure each evidence entry adds to the packet: ids, kind, timestamps and flags. The largest
|
|
215
|
+
* retained packet averaged 200 bytes. */
|
|
216
|
+
const ENTRY_STRUCTURE_BYTES = 256;
|
|
217
|
+
/**
|
|
218
|
+
* The expected cost of an analysis for this many participants before any evidence exists: from a
|
|
219
|
+
* packet with no evidence to one at the evidence limits with every capture at the image-token
|
|
220
|
+
* ceiling. Undefined for Codex and for a model without rates.
|
|
221
|
+
*/
|
|
222
|
+
export function analysisCostRange(config, participants) {
|
|
223
|
+
if (config.provider === "codex")
|
|
224
|
+
return undefined;
|
|
225
|
+
const rate = MODEL_RATES[config.model];
|
|
226
|
+
if (!rate || rate.placeholder)
|
|
227
|
+
return undefined;
|
|
228
|
+
const prompt = Buffer.byteLength(instructions(config)) +
|
|
229
|
+
Buffer.byteLength(JSON.stringify(analysisResultJsonSchema));
|
|
230
|
+
const size = {
|
|
231
|
+
participants: Math.min(participants, EVIDENCE_LIMITS.participants),
|
|
232
|
+
outputAllowance: config.maxOutputTokens,
|
|
233
|
+
};
|
|
234
|
+
const low = estimateAnalysisCost(rate, { ...size, textBytes: prompt, imageTokens: 0 });
|
|
235
|
+
const high = estimateAnalysisCost(rate, {
|
|
236
|
+
...size,
|
|
237
|
+
textBytes: prompt + EVIDENCE_LIMITS.textBytes + EVIDENCE_LIMITS.evidence * ENTRY_STRUCTURE_BYTES,
|
|
238
|
+
imageTokens: EVIDENCE_LIMITS.captures * HIGH_DETAIL_IMAGE_TOKEN_CEILING,
|
|
239
|
+
});
|
|
240
|
+
return { low: low.expectedCostUsd, high: high.expectedCostUsd };
|
|
241
|
+
}
|
|
211
242
|
/** Only for an omitted output limit. Preserve the established allowance when
|
|
212
243
|
* more reasoning/report space would refuse a study its declared budget admits.
|
|
213
244
|
* This is one pre-dispatch choice, never a fallback request or a budget increase. */
|
|
@@ -462,9 +493,11 @@ function settleCompletedOutput(artifact, input, config, admission, response, onR
|
|
|
462
493
|
artifact.result = checked.result;
|
|
463
494
|
artifact.status = input.coverage.complete ? "complete" : "partial";
|
|
464
495
|
artifact.error = null;
|
|
496
|
+
// A bill above the expected cost is a normal outcome. Only one above the worst case or the cap
|
|
497
|
+
// shows the estimate was wrong.
|
|
465
498
|
if (config.provider !== "codex" &&
|
|
466
499
|
((response.usage?.output ?? 0) > config.maxOutputTokens ||
|
|
467
|
-
(artifact.usage.estimatedCostUsd ?? 0) > (admission.
|
|
500
|
+
(artifact.usage.estimatedCostUsd ?? 0) > (admission.worstCaseCostUsd ?? config.maxCostUsd) ||
|
|
468
501
|
(artifact.usage.estimatedCostUsd ?? 0) > config.maxCostUsd)) {
|
|
469
502
|
artifact.status = "partial";
|
|
470
503
|
artifact.error = "analysis_admission_estimate_exceeded";
|
|
@@ -16,7 +16,7 @@ const HIGH_DETAIL_SIZING = Object.freeze({
|
|
|
16
16
|
"gpt-5.5": { patchBudget: 2500, multiplierTenths: 12 },
|
|
17
17
|
});
|
|
18
18
|
/** 2,500 patches × 1.2: what any image costs on these models when its size is unknown. */
|
|
19
|
-
const HIGH_DETAIL_IMAGE_TOKEN_CEILING = 3000;
|
|
19
|
+
export const HIGH_DETAIL_IMAGE_TOKEN_CEILING = 3000;
|
|
20
20
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
21
21
|
/** Width and height from a PNG data URL's IHDR chunk; undefined for anything else. */
|
|
22
22
|
function pngDimensions(dataUrl) {
|
package/dist/analysis/job.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AnalyzeResult } from "./service.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { type PreparedRunArtifactPaths } from "../run/paths.js";
|
|
4
|
+
import type { RefusedAnalysisCost } from "./admission.js";
|
|
4
5
|
export declare const AUTOMATIC_ANALYSIS_DIRECTORY = "analysis-automatic";
|
|
5
6
|
export declare const AUTOMATIC_ANALYSIS_STALE_MS = 15000;
|
|
6
7
|
/** Execution metadata only. Never a participant outcome or permission to dispatch. */
|
|
@@ -11,6 +12,8 @@ export interface AutomaticAnalysisView {
|
|
|
11
12
|
/** Safe stable code, not provider text. */
|
|
12
13
|
reason: string | null;
|
|
13
14
|
updatedAt: string;
|
|
15
|
+
/** What the analysis would have cost, when admission refused it for its cost. */
|
|
16
|
+
admission?: RefusedAnalysisCost;
|
|
14
17
|
}
|
|
15
18
|
export interface AutomaticAnalysisOutcome {
|
|
16
19
|
state: "complete" | "partial" | "failed" | "cancelled" | "skipped" | "unknown";
|
|
@@ -69,6 +72,11 @@ declare const jobSchema: z.ZodObject<{
|
|
|
69
72
|
receiptSha256: z.ZodNullable<z.ZodString>;
|
|
70
73
|
startedAt: z.ZodNullable<z.ZodISODateTime>;
|
|
71
74
|
finishedAt: z.ZodNullable<z.ZodISODateTime>;
|
|
75
|
+
admission: z.ZodOptional<z.ZodObject<{
|
|
76
|
+
expectedCostUsd: z.ZodNumber;
|
|
77
|
+
worstCaseCostUsd: z.ZodNumber;
|
|
78
|
+
maxCostUsd: z.ZodNumber;
|
|
79
|
+
}, z.core.$strict>>;
|
|
72
80
|
}, z.core.$strict>;
|
|
73
81
|
type JobRecord = z.infer<typeof jobSchema>;
|
|
74
82
|
/** Also guards direct HTML projection callers, not only the contained-file reader. */
|
|
@@ -86,7 +94,7 @@ export declare function readAutomaticAnalysisPrepared(prepared: PreparedRunArtif
|
|
|
86
94
|
export interface AutomaticAnalysisJob {
|
|
87
95
|
readonly attemptId: string;
|
|
88
96
|
/** Serialized, awaited durability boundary. A failed write must prevent dispatch. */
|
|
89
|
-
update(update: Partial<Pick<JobRecord, "state" | "analysisId" | "reason" | "sourceRunSha256" | "inputDigest" | "startedAt" | "analysisSha256" | "receiptSha256">>): Promise<void>;
|
|
97
|
+
update(update: Partial<Pick<JobRecord, "state" | "analysisId" | "reason" | "sourceRunSha256" | "inputDigest" | "startedAt" | "analysisSha256" | "receiptSha256" | "admission">>): Promise<void>;
|
|
90
98
|
touch(): Promise<void>;
|
|
91
99
|
cancellationRequested(): Promise<boolean>;
|
|
92
100
|
}
|
package/dist/analysis/job.js
CHANGED
|
@@ -52,6 +52,12 @@ const reasons = [
|
|
|
52
52
|
const id = z.string().regex(ANALYSIS_ID_PATTERN);
|
|
53
53
|
const date = z.iso.datetime();
|
|
54
54
|
const digest = z.string().regex(SHA256_HEX_PATTERN);
|
|
55
|
+
const usd = z.number().min(0).max(1e9);
|
|
56
|
+
const refusedCostSchema = z.strictObject({
|
|
57
|
+
expectedCostUsd: usd,
|
|
58
|
+
worstCaseCostUsd: usd,
|
|
59
|
+
maxCostUsd: usd,
|
|
60
|
+
});
|
|
55
61
|
const jobSchema = z.strictObject({
|
|
56
62
|
schema: z.literal(AUTOMATIC_ANALYSIS_SCHEMA),
|
|
57
63
|
runId: id,
|
|
@@ -70,6 +76,7 @@ const jobSchema = z.strictObject({
|
|
|
70
76
|
receiptSha256: digest.nullable(),
|
|
71
77
|
startedAt: date.nullable(),
|
|
72
78
|
finishedAt: date.nullable(),
|
|
79
|
+
admission: refusedCostSchema.optional(),
|
|
73
80
|
});
|
|
74
81
|
const cancelSchema = z.strictObject({
|
|
75
82
|
schema: z.literal(CANCEL_SCHEMA),
|
|
@@ -111,11 +118,12 @@ export function projectAutomaticAnalysisView(value) {
|
|
|
111
118
|
if (value === undefined)
|
|
112
119
|
return undefined;
|
|
113
120
|
const parsed = jobSchema
|
|
114
|
-
.pick({ state: true, analysisId: true, reason: true, updatedAt: true })
|
|
121
|
+
.pick({ state: true, analysisId: true, reason: true, updatedAt: true, admission: true })
|
|
115
122
|
.safeParse(value);
|
|
116
123
|
if (!parsed.success || containsSensitive(JSON.stringify(parsed.data)))
|
|
117
124
|
return unknown();
|
|
118
|
-
|
|
125
|
+
const { admission, ...view } = parsed.data;
|
|
126
|
+
return admission === undefined ? view : { ...view, admission };
|
|
119
127
|
}
|
|
120
128
|
async function bindJob(prepared) {
|
|
121
129
|
await validatePreparedRunRootIdentity(prepared);
|
|
@@ -162,6 +170,7 @@ export async function readAutomaticAnalysisPrepared(prepared, now = Date.now())
|
|
|
162
170
|
analysisId: record.analysisId,
|
|
163
171
|
reason: record.reason,
|
|
164
172
|
updatedAt: record.updatedAt,
|
|
173
|
+
...(record.admission === undefined ? {} : { admission: record.admission }),
|
|
165
174
|
};
|
|
166
175
|
const updated = Date.parse(record.updatedAt);
|
|
167
176
|
if (updated > now || (pending(record.state) && now - updated > AUTOMATIC_ANALYSIS_STALE_MS)) {
|
package/dist/analysis/service.js
CHANGED
|
@@ -17,6 +17,7 @@ import { appendAnalysisCorrection, assertAnalysisPublicationCapacity, listAnalys
|
|
|
17
17
|
import { beginAnalysisExecution, writeAnalysisExecutionReceipt } from "./store-executions.js";
|
|
18
18
|
import { keepRejectedAnalysisOutput } from "./diagnostics.js";
|
|
19
19
|
import { loadAnalysis } from "./load.js";
|
|
20
|
+
import { admittingMaxCost, costRefusalText, refusedCost } from "./admission.js";
|
|
20
21
|
import { hashAnalysisValue } from "./validation.js";
|
|
21
22
|
import { ANALYSIS_CORRECTION_SCHEMA, } from "./types.js";
|
|
22
23
|
import { RUN_BUNDLE_FILE } from "../run/bundle.js";
|
|
@@ -188,17 +189,19 @@ function analyzeConfigRefusal(config) {
|
|
|
188
189
|
}
|
|
189
190
|
/** The result when admission refuses the attempt, or when a dry run stops after admission. */
|
|
190
191
|
function admissionOnlyResult(base, config) {
|
|
191
|
-
if (!base.admission.allowed)
|
|
192
|
+
if (!base.admission.allowed) {
|
|
193
|
+
const cost = refusedCost(base.admission);
|
|
192
194
|
return {
|
|
193
195
|
...base,
|
|
194
196
|
ok: false,
|
|
195
197
|
error: {
|
|
196
198
|
code: base.admission.error ?? "analysis_admission_denied",
|
|
197
|
-
message:
|
|
198
|
-
?
|
|
199
|
+
message: cost
|
|
200
|
+
? `${costRefusalText(cost)} To run it, raise the cap: humanish analyze --run ${base.run} --max-cost ${admittingMaxCost(cost)}`
|
|
199
201
|
: "The analysis input or configuration did not pass admission. No provider request was sent.",
|
|
200
202
|
},
|
|
201
203
|
};
|
|
204
|
+
}
|
|
202
205
|
return {
|
|
203
206
|
...base,
|
|
204
207
|
ok: true,
|
|
@@ -29,7 +29,7 @@ export function registerAnalyzeCommand(parent, io) {
|
|
|
29
29
|
.option("--run <id>", RUN_OPTION_DESCRIPTION, "latest")
|
|
30
30
|
.option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
|
|
31
31
|
.option("--provider <name>", "Analyst: openai (default) or restricted codex account. No provider fallback.")
|
|
32
|
-
.option("--max-cost <usd>", "Required for OpenAI, also with --dry-run: send nothing when the
|
|
32
|
+
.option("--max-cost <usd>", "Required for OpenAI, also with --dry-run: send nothing when the expected cost in USD, plus a 10% margin, is above this. It does not limit billing. Unsupported for Codex.")
|
|
33
33
|
.option("--model <id>", "Supported vision model. OpenAI uses high effort; qualified Codex account analysis uses low effort.", DEFAULT_ANALYSIS_MODEL)
|
|
34
34
|
.option("--question <text>", "Additional reviewer question; does not change participant instructions.")
|
|
35
35
|
.option("--timeout-ms <ms>", "Request timeout, at most 600000 ms.", String(DEFAULT_ANALYSIS_TIMEOUT_MS))
|
|
@@ -125,7 +125,7 @@ async function handleAnalyze(io, options, command) {
|
|
|
125
125
|
if (value.ok && value.dryRun && selected.config?.provider === "codex")
|
|
126
126
|
return "Local evidence and configuration passed admission. Codex CLI, login and model access were not checked. Dollar cost and output-token ceiling are unknown. No provider request sent.\n";
|
|
127
127
|
if (value.ok && value.dryRun)
|
|
128
|
-
return
|
|
128
|
+
return admittedDryRunText(value.admission);
|
|
129
129
|
const lines = [];
|
|
130
130
|
if (value.artifactPath)
|
|
131
131
|
lines.push(`${value.reused ? "Reused" : "Saved"} ${value.status} analysis: ${value.artifactPath}`);
|
|
@@ -231,3 +231,8 @@ async function handleAnalyzeCorrect(io, options, command) {
|
|
|
231
231
|
io.setExitCode(2);
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
|
+
/** An admitted dry run's costs: the expected cost, the worst case and the cap it was admitted under. */
|
|
235
|
+
function admittedDryRunText(admission) {
|
|
236
|
+
const usd = (value) => value === null || value === undefined ? "unknown" : `$${value.toFixed(2)}`;
|
|
237
|
+
return `Expected cost: ${usd(admission?.estimatedCostUsd)}. Worst case: ${usd(admission?.worstCaseCostUsd)}, if the analyst writes its whole ${admission?.outputTokenAllowance ?? "unknown"}-token output allowance, reasoning included. Admitted under the $${admission?.maxCostUsd ?? "unknown"} cap. No request sent.\n`;
|
|
238
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { renderObserver } from "../../observer/render.js";
|
|
2
|
+
import { loadRunBundlePrepared, resolveRunPath } from "../../run/locate.js";
|
|
3
|
+
import { addRunNote, formatRunTime, readRunNotes, runParticipantCaptions, } from "../../run/notes.js";
|
|
4
|
+
import { streamParticipantIdOf } from "../../run/participant-records.js";
|
|
5
|
+
import { resolvePhysicalCwd, runIdOf } from "../../run/paths.js";
|
|
6
|
+
import { runNotFoundMessage } from "../../run/run-not-found.js";
|
|
7
|
+
import { cli } from "../invocation.js";
|
|
8
|
+
import { CWD_OPTION_DESCRIPTION, humanError, JSON_OPTION_DESCRIPTION, writeResult, } from "../io.js";
|
|
9
|
+
const NOTES_RESULT_SCHEMA = "humanish.notes-result.v1";
|
|
10
|
+
export function registerNotesCommand(parent, io) {
|
|
11
|
+
parent
|
|
12
|
+
.command("notes")
|
|
13
|
+
.description("List the reviewer notes on a run, or add one at a moment of its recording. A note's time counts from the run's first timed capture, as the Observer's study timeline shows it.")
|
|
14
|
+
.summary("List or add reviewer notes on a run.")
|
|
15
|
+
// The text is read from the words after the run, not declared, so the root command list
|
|
16
|
+
// stays as narrow as its other rows and an unquoted note still arrives whole.
|
|
17
|
+
.usage("[options] <run> [text...]")
|
|
18
|
+
.argument("<run>", "Run id, or latest.")
|
|
19
|
+
.allowExcessArguments(true)
|
|
20
|
+
.option("--add", "Add a note. Its text is the words after the run; --at gives the moment.")
|
|
21
|
+
.option("--at <mm:ss>", "With --add: the moment, in minutes and seconds, such as 02:31.")
|
|
22
|
+
.option("--participant <id>", "With --add: the participant's id from the study, or its stream id. Leave it out for a note on the whole run.")
|
|
23
|
+
.option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
|
|
24
|
+
.option("--json", JSON_OPTION_DESCRIPTION)
|
|
25
|
+
.addHelpText("after", [
|
|
26
|
+
"",
|
|
27
|
+
"Examples:",
|
|
28
|
+
" humanish notes latest",
|
|
29
|
+
' humanish notes latest --add --at 02:31 "The save button was hidden here."',
|
|
30
|
+
' humanish notes <runId> --add --at 00:45 --participant <id> "They gave up."',
|
|
31
|
+
"",
|
|
32
|
+
"Each note is saved in the run directory as notes/<id>.json. Text that looks like a secret",
|
|
33
|
+
"or a local path is replaced before it is saved, and verify scans notes like other run text.",
|
|
34
|
+
].join("\n"))
|
|
35
|
+
.action((run, options, command) => {
|
|
36
|
+
const words = command.args.slice(1);
|
|
37
|
+
return handleNotes(io, run, words.length > 0 ? words.join(" ") : undefined, options, command);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** Minutes and seconds as the Observer shows them, `02:31`, in milliseconds; null otherwise. */
|
|
41
|
+
function parseRunTime(value) {
|
|
42
|
+
const match = /^(\d{1,4}):([0-5]\d)$/.exec(value.trim());
|
|
43
|
+
return match ? (Number(match[1]) * 60 + Number(match[2])) * 1000 : null;
|
|
44
|
+
}
|
|
45
|
+
const byRunTime = (notes) => [...notes].sort((left, right) => left.atMs - right.atMs || left.createdAt.localeCompare(right.createdAt));
|
|
46
|
+
async function handleNotes(io, run, text, options, command) {
|
|
47
|
+
const base = {
|
|
48
|
+
schema: NOTES_RESULT_SCHEMA,
|
|
49
|
+
cwd: options.cwd,
|
|
50
|
+
run,
|
|
51
|
+
notes: [],
|
|
52
|
+
participants: [],
|
|
53
|
+
warnings: [],
|
|
54
|
+
};
|
|
55
|
+
const fail = (code, message) => {
|
|
56
|
+
writeResult(command, io, { ...base, ok: false, error: { code, message } }, formatNotesHuman);
|
|
57
|
+
io.setExitCode(2);
|
|
58
|
+
};
|
|
59
|
+
const adding = options.add === true;
|
|
60
|
+
if (!adding && (text !== undefined || options.at !== undefined))
|
|
61
|
+
return fail("HUMANISH_NOTES_OPTION_CONFLICT", `Adding a note needs --add: ${cli(`notes ${run} --add --at 02:31 "text"`)}.`);
|
|
62
|
+
if (adding && (options.at === undefined || text === undefined))
|
|
63
|
+
return fail("HUMANISH_NOTES_OPTION_CONFLICT", `--add needs --at and the note's text: ${cli(`notes ${run} --add --at 02:31 "text"`)}.`);
|
|
64
|
+
const atMs = options.at === undefined ? null : parseRunTime(options.at);
|
|
65
|
+
if (adding && atMs === null)
|
|
66
|
+
return fail("HUMANISH_NOTE_INVALID_TIME", `--at ${options.at ?? ""} is not a time on the run clock. Give minutes and seconds from the run's first capture, as the Observer shows them, such as 02:31.`);
|
|
67
|
+
const cwd = await resolvePhysicalCwd(options.cwd);
|
|
68
|
+
const prepared = await resolveRunPath(cwd, run).catch(() => null);
|
|
69
|
+
if (!prepared)
|
|
70
|
+
return fail("HUMANISH_RUN_NOT_FOUND", await runNotFoundMessage(cwd, run));
|
|
71
|
+
const runId = runIdOf(prepared);
|
|
72
|
+
let added;
|
|
73
|
+
const warnings = [];
|
|
74
|
+
if (adding && atMs !== null && text !== undefined) {
|
|
75
|
+
const result = await addRunNote(prepared, {
|
|
76
|
+
atMs,
|
|
77
|
+
participant: options.participant ?? null,
|
|
78
|
+
text,
|
|
79
|
+
});
|
|
80
|
+
if (!result.ok)
|
|
81
|
+
return fail(result.error.code, result.error.message);
|
|
82
|
+
added = result.note;
|
|
83
|
+
if (result.scrubbed)
|
|
84
|
+
warnings.push("Part of the note looked like a secret or a local path, so it was replaced before the note was saved.");
|
|
85
|
+
// The saved Observer page shows notes read-only, so it is rendered again with the new one.
|
|
86
|
+
const rendered = await renderObserver(cwd, runId, { open: false, expectedRun: prepared })
|
|
87
|
+
.then((observer) => observer.ok)
|
|
88
|
+
.catch(() => false);
|
|
89
|
+
if (!rendered)
|
|
90
|
+
warnings.push(`The note is saved, but the run's saved Observer page could not be rendered again. ${cli(`observe --run ${runId}`)} shows it.`);
|
|
91
|
+
}
|
|
92
|
+
const notes = await readRunNotes(prepared);
|
|
93
|
+
// A note file that cannot be read is named and left as it is; the others are listed.
|
|
94
|
+
warnings.push(...notes.skipped);
|
|
95
|
+
const result = {
|
|
96
|
+
...base,
|
|
97
|
+
ok: true,
|
|
98
|
+
run: runId,
|
|
99
|
+
notes: byRunTime(notes.notes),
|
|
100
|
+
participants: await participantsOf(cwd, prepared),
|
|
101
|
+
...(added === undefined ? {} : { added }),
|
|
102
|
+
warnings,
|
|
103
|
+
};
|
|
104
|
+
writeResult(command, io, result, formatNotesHuman);
|
|
105
|
+
io.setExitCode(0);
|
|
106
|
+
}
|
|
107
|
+
/** The run's participants with their captions; none when its run.json cannot be read. */
|
|
108
|
+
async function participantsOf(cwd, prepared) {
|
|
109
|
+
const loaded = await loadRunBundlePrepared(cwd, prepared).catch(() => null);
|
|
110
|
+
if (!loaded)
|
|
111
|
+
return [];
|
|
112
|
+
const captions = runParticipantCaptions(loaded.bundle);
|
|
113
|
+
return loaded.bundle.streams.map((stream) => {
|
|
114
|
+
const participantId = streamParticipantIdOf(stream);
|
|
115
|
+
return {
|
|
116
|
+
id: stream.id,
|
|
117
|
+
...(participantId === undefined ? {} : { participantId }),
|
|
118
|
+
caption: captions.get(stream.id) ?? stream.id,
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** Who a note is about, as a person reads it. */
|
|
123
|
+
function participantName(result, participant) {
|
|
124
|
+
if (participant === null)
|
|
125
|
+
return "whole run";
|
|
126
|
+
return result.participants.find((entry) => entry.id === participant)?.caption ?? participant;
|
|
127
|
+
}
|
|
128
|
+
function noteLine(note, name, width) {
|
|
129
|
+
const head = ` ${formatRunTime(note.atMs)} ${name.padEnd(width)} `;
|
|
130
|
+
const [first = "", ...rest] = note.text.split("\n");
|
|
131
|
+
return [`${head}${note.author}: ${first}`, ...rest.map((line) => ` ${line}`)].join("\n");
|
|
132
|
+
}
|
|
133
|
+
function formatNotesHuman(result) {
|
|
134
|
+
if (!result.ok)
|
|
135
|
+
return humanError(result.error);
|
|
136
|
+
const warnings = result.warnings.map((warning) => `warning: ${warning}\n`).join("");
|
|
137
|
+
if (result.added)
|
|
138
|
+
return `Added a note at ${formatRunTime(result.added.atMs)} ${result.added.participant === null ? "on the whole run" : `for ${participantName(result, result.added.participant)}`} to run ${result.run}. The Observer marks it on the study timeline, and ${cli(`notes ${result.run}`)} lists every note.\n${warnings}`;
|
|
139
|
+
if (result.notes.length === 0)
|
|
140
|
+
return `Run ${result.run} has no reviewer notes. Add one: ${cli(`notes ${result.run} --add --at 00:30 "text"`)}\n${warnings}`;
|
|
141
|
+
const names = result.notes.map((note) => participantName(result, note.participant));
|
|
142
|
+
const width = Math.max(...names.map((name) => name.length));
|
|
143
|
+
return `${[`Reviewer notes on run ${result.run}: ${result.notes.length}`, ...result.notes.map((note, index) => noteLine(note, names[index], width))].join("\n")}\n${warnings}`;
|
|
144
|
+
}
|
|
@@ -152,7 +152,7 @@ async function observeRun(io, options, command) {
|
|
|
152
152
|
...(openResult.command ? { openCommand: openResult.command } : {}),
|
|
153
153
|
warnings: [
|
|
154
154
|
...rendered.warnings,
|
|
155
|
-
"Observer is served
|
|
155
|
+
"Observer is served over loopback http on 127.0.0.1; only this run's bundle directory is exposed, and the one change the page can make is adding a reviewer note.",
|
|
156
156
|
...(openResult.warning ? [openResult.warning] : []),
|
|
157
157
|
],
|
|
158
158
|
};
|