humanish 0.114.0 → 0.116.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 (204) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CONTEXT.md +1 -1
  3. package/README.md +7 -6
  4. package/TELEMETRY.md +6 -0
  5. package/dist/actors/closing-report-limits.d.ts +11 -0
  6. package/dist/actors/closing-report-limits.js +13 -0
  7. package/dist/actors/closing-report.d.ts +68 -0
  8. package/dist/actors/closing-report.js +55 -0
  9. package/dist/actors/codex/app-server-ui.js +11 -4
  10. package/dist/actors/codex/restricted-participant-policy.d.ts +4 -47
  11. package/dist/actors/codex/restricted-participant-policy.js +12 -40
  12. package/dist/actors/computer-use/loop/debrief.d.ts +1 -10
  13. package/dist/actors/computer-use/loop/debrief.js +68 -70
  14. package/dist/actors/computer-use/loop/impressions.d.ts +2 -2
  15. package/dist/actors/computer-use/loop/impressions.js +6 -1
  16. package/dist/actors/computer-use/loop.d.ts +0 -1
  17. package/dist/actors/computer-use/loop.js +3 -3
  18. package/dist/actors/computer-use/openai-conversation.d.ts +1 -1
  19. package/dist/actors/computer-use/openai-conversation.js +3 -29
  20. package/dist/actors/computer-use/openai-provider.js +11 -5
  21. package/dist/actors/contract.d.ts +2 -20
  22. package/dist/actors/contract.js +0 -13
  23. package/dist/actors/scripted-browser/steps.js +6 -5
  24. package/dist/analysis/admission.d.ts +14 -4
  25. package/dist/analysis/admission.js +14 -9
  26. package/dist/analysis/analysis-limits.d.ts +35 -0
  27. package/dist/analysis/analysis-limits.js +41 -0
  28. package/dist/analysis/execute.d.ts +5 -0
  29. package/dist/analysis/execute.js +3 -0
  30. package/dist/analysis/finding-lead.d.ts +27 -0
  31. package/dist/analysis/finding-lead.js +23 -0
  32. package/dist/analysis/service.js +5 -3
  33. package/dist/analysis/validation.js +14 -21
  34. package/dist/cli/commands/analyze.js +2 -1
  35. package/dist/cli/commands/notes.js +5 -3
  36. package/dist/cli/commands/run-signals.js +5 -1
  37. package/dist/cli/commands/runs.js +2 -1
  38. package/dist/cli/commands/setup.js +3 -3
  39. package/dist/cli/commands/study-format.d.ts +5 -2
  40. package/dist/cli/commands/study-format.js +29 -9
  41. package/dist/cli/commands/study-route-computer-use.js +2 -2
  42. package/dist/cli/commands/study-route-scripted.js +1 -1
  43. package/dist/cli/commands/tui.js +59 -17
  44. package/dist/cli/doctor.js +9 -2
  45. package/dist/cli/findings-text.d.ts +5 -0
  46. package/dist/cli/findings-text.js +169 -0
  47. package/dist/cli/findings.d.ts +0 -4
  48. package/dist/cli/findings.js +11 -195
  49. package/dist/cli/io.js +10 -8
  50. package/dist/cli/telemetry.js +10 -22
  51. package/dist/cli/update-check.js +10 -19
  52. package/dist/cli/user-config.d.ts +11 -0
  53. package/dist/cli/user-config.js +25 -0
  54. package/dist/comms/connections.js +4 -3
  55. package/dist/comms/email-catch.js +7 -31
  56. package/dist/comms/setup.js +18 -12
  57. package/dist/evidence/encoded-text.d.ts +12 -11
  58. package/dist/evidence/encoded-text.js +81 -70
  59. package/dist/evidence/redaction.d.ts +11 -0
  60. package/dist/evidence/redaction.js +30 -11
  61. package/dist/evidence/secret-scrub.d.ts +26 -3
  62. package/dist/evidence/secret-scrub.js +172 -38
  63. package/dist/feedback/draft.d.ts +1 -1
  64. package/dist/feedback/draft.js +13 -16
  65. package/dist/feedback/export-bundle.js +21 -17
  66. package/dist/feedback/export.js +32 -46
  67. package/dist/feedback/feedback.js +31 -26
  68. package/dist/guest/desktop-keys.js +59 -5
  69. package/dist/keys/key-resolution.d.ts +9 -0
  70. package/dist/keys/key-resolution.js +62 -55
  71. package/dist/keys/key-status.d.ts +2 -0
  72. package/dist/keys/key-status.js +9 -16
  73. package/dist/observer/artifact.d.ts +1 -1
  74. package/dist/observer/http.d.ts +7 -0
  75. package/dist/observer/http.js +19 -1
  76. package/dist/observer/notes-route.d.ts +0 -1
  77. package/dist/observer/notes-route.js +24 -44
  78. package/dist/observer/pinned-files.js +4 -19
  79. package/dist/observer/render.js +6 -4
  80. package/dist/observer/run-routes.js +13 -28
  81. package/dist/observer/serve.js +2 -1
  82. package/dist/observer-app.html +7 -7
  83. package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
  84. package/dist/routes/computer-use/bundle-parts.js +2 -2
  85. package/dist/routes/computer-use/bundle.d.ts +4 -5
  86. package/dist/routes/computer-use/bundle.js +1 -3
  87. package/dist/routes/computer-use/fanout-records.js +10 -11
  88. package/dist/routes/computer-use/live-phase.d.ts +1 -1
  89. package/dist/routes/computer-use/live-phase.js +0 -1
  90. package/dist/routes/computer-use/participant-execution.d.ts +3 -2
  91. package/dist/routes/computer-use/participant-runs.d.ts +6 -2
  92. package/dist/routes/computer-use/participant-runs.js +9 -5
  93. package/dist/routes/computer-use/plan.d.ts +3 -0
  94. package/dist/routes/computer-use/plan.js +14 -11
  95. package/dist/routes/computer-use/rerun-selection.js +2 -2
  96. package/dist/routes/computer-use/result.js +3 -6
  97. package/dist/routes/computer-use/setup.d.ts +4 -6
  98. package/dist/routes/computer-use/setup.js +2 -6
  99. package/dist/routes/computer-use/single-bundle.d.ts +4 -5
  100. package/dist/routes/computer-use/single-bundle.js +22 -25
  101. package/dist/routes/computer-use/subject-projection.d.ts +4 -8
  102. package/dist/routes/computer-use/subject-projection.js +9 -31
  103. package/dist/routes/computer-use/types.d.ts +4 -41
  104. package/dist/routes/scripted/scenario.js +28 -12
  105. package/dist/routes/scripted/session-result.js +6 -3
  106. package/dist/routes/shared-world/bundle-records.js +2 -8
  107. package/dist/routes/shared-world/participant-specs.d.ts +0 -2
  108. package/dist/routes/shared-world/participant-specs.js +1 -20
  109. package/dist/routes/shared-world/plan.d.ts +3 -0
  110. package/dist/routes/shared-world/plan.js +23 -3
  111. package/dist/routes/shared-world/setup.js +2 -2
  112. package/dist/routes/terminal/ledger.d.ts +9 -9
  113. package/dist/routes/terminal/ledger.js +63 -40
  114. package/dist/routes/terminal/live-finish.js +7 -10
  115. package/dist/routes/terminal/plan.d.ts +3 -0
  116. package/dist/routes/terminal/plan.js +8 -5
  117. package/dist/routes/terminal/token-usage.d.ts +1 -2
  118. package/dist/routes/terminal/token-usage.js +4 -6
  119. package/dist/routes/terminal/types.d.ts +9 -1
  120. package/dist/run/bundle-shape.js +6 -1
  121. package/dist/run/bundle.d.ts +5 -3
  122. package/dist/run/contained-output.d.ts +59 -10
  123. package/dist/run/contained-output.js +160 -68
  124. package/dist/run/detail.d.ts +2 -1
  125. package/dist/run/detail.js +12 -7
  126. package/dist/run/escape-sequences.d.ts +11 -0
  127. package/dist/run/escape-sequences.js +93 -0
  128. package/dist/run/evidence-files.d.ts +8 -1
  129. package/dist/run/evidence-files.js +24 -18
  130. package/dist/run/locate.d.ts +32 -3
  131. package/dist/run/locate.js +45 -40
  132. package/dist/run/note-files.d.ts +26 -45
  133. package/dist/run/note-files.js +86 -58
  134. package/dist/run/note-shape.d.ts +35 -0
  135. package/dist/run/note-shape.js +20 -0
  136. package/dist/run/notes.d.ts +3 -14
  137. package/dist/run/notes.js +23 -75
  138. package/dist/run/outcomes.d.ts +24 -0
  139. package/dist/run/outcomes.js +19 -0
  140. package/dist/run/participant-records.d.ts +2 -0
  141. package/dist/run/participant-records.js +17 -2
  142. package/dist/run/paths.d.ts +2 -0
  143. package/dist/run/paths.js +5 -1
  144. package/dist/run/preflight-receipts.js +13 -8
  145. package/dist/run/project.js +7 -4
  146. package/dist/run/reclaim-outcomes.d.ts +8 -6
  147. package/dist/run/reclaim-outcomes.js +10 -6
  148. package/dist/run/reclaim.d.ts +5 -0
  149. package/dist/run/reclaim.js +43 -16
  150. package/dist/run/run-clock.d.ts +50 -0
  151. package/dist/run/run-clock.js +66 -0
  152. package/dist/run/run.js +5 -2
  153. package/dist/run/sandbox-ids.d.ts +7 -2
  154. package/dist/run/sandbox-ids.js +27 -11
  155. package/dist/run/secrets.d.ts +2 -1
  156. package/dist/run/secrets.js +18 -22
  157. package/dist/run/stored-runs.js +14 -7
  158. package/dist/run/transient-comms-secrets.d.ts +6 -5
  159. package/dist/run/transient-comms-secrets.js +23 -39
  160. package/dist/run-study.js +22 -13
  161. package/dist/study/adapter-scorer-loader.js +6 -4
  162. package/dist/study/discover.js +9 -6
  163. package/dist/study/init-paths.js +7 -5
  164. package/dist/study/persona-resolve.d.ts +2 -2
  165. package/dist/study/persona-resolve.js +9 -5
  166. package/dist/study/plan-types.d.ts +2 -2
  167. package/dist/study/plan.d.ts +5 -3
  168. package/dist/study/plan.js +18 -9
  169. package/dist/study/preflight-probes.js +11 -3
  170. package/dist/study/preflight.js +9 -5
  171. package/dist/study/summary.d.ts +12 -7
  172. package/dist/study/summary.js +24 -24
  173. package/dist/study/warnings.d.ts +19 -12
  174. package/dist/study/warnings.js +39 -17
  175. package/dist/substrates/e2b/connection.d.ts +12 -0
  176. package/dist/substrates/e2b/connection.js +35 -0
  177. package/dist/substrates/e2b/desktop-executor.d.ts +1 -3
  178. package/dist/substrates/e2b/desktop-executor.js +42 -9
  179. package/dist/substrates/e2b/lifetime.d.ts +24 -4
  180. package/dist/substrates/e2b/lifetime.js +41 -4
  181. package/dist/substrates/e2b/sandbox.d.ts +4 -3
  182. package/dist/substrates/e2b/sandbox.js +13 -18
  183. package/dist/substrates/e2b/sdk.d.ts +20 -9
  184. package/dist/substrates/local/capacity.d.ts +18 -2
  185. package/dist/substrates/local/capacity.js +21 -2
  186. package/dist/substrates/local/runtime-host.js +21 -14
  187. package/dist/substrates/local/runtime.js +3 -11
  188. package/dist/tui/contract.d.ts +30 -5
  189. package/dist/tui/contract.js +4 -1
  190. package/dist/tui-app.js +149 -147
  191. package/dist/verify/artifacts.d.ts +9 -2
  192. package/dist/verify/artifacts.js +37 -13
  193. package/dist/verify/liveness.js +19 -5
  194. package/dist/verify/verify.js +54 -27
  195. package/docs/architecture/actor-contract.md +3 -3
  196. package/docs/architecture/guest-desktop.md +7 -2
  197. package/docs/architecture/restricted-codex-analysis.md +1 -1
  198. package/docs/architecture/state-driven-executor.md +5 -3
  199. package/docs/contracts/run-bundle.md +25 -13
  200. package/docs/contracts/schemas.md +38 -29
  201. package/docs/contracts/study-analysis.md +22 -5
  202. package/docs/product/automatic-analysis.md +7 -1
  203. package/package.json +8 -7
  204. package/skills/humanish/SKILL.md +1 -2
@@ -0,0 +1,169 @@
1
+ // The text of a run's `humanish.analysis-findings.v1` view (findings.ts): the full block `review`
2
+ // and `analyze show` print, and the summary at the end of a live `run`. It reads only the view.
3
+ import path from "node:path";
4
+ import { findingLead } from "../analysis/finding-lead.js";
5
+ import { plural } from "../run/text.js";
6
+ const IMPACT_TEXT = {
7
+ blocked_task: "blocked task",
8
+ friction: "friction",
9
+ recovery: "recovery",
10
+ uncertain: "uncertain",
11
+ };
12
+ const RECOVERY_TEXT = {
13
+ recovered: "recovered",
14
+ not_observed: "no recovery observed",
15
+ unknown: "recovery unknown",
16
+ };
17
+ /** `+1:05` from milliseconds since the first retained capture. */
18
+ function elapsedText(ms) {
19
+ const seconds = Math.floor(ms / 1000);
20
+ const minutes = Math.floor(seconds / 60);
21
+ const hours = Math.floor(minutes / 60);
22
+ const ss = String(seconds % 60).padStart(2, "0");
23
+ return hours > 0
24
+ ? `+${hours}:${String(minutes % 60).padStart(2, "0")}:${ss}`
25
+ : `+${minutes}:${ss}`;
26
+ }
27
+ /** One participant's cited moments: each frame once, at its earliest cited time. */
28
+ function momentsText(evidence) {
29
+ const frames = new Map();
30
+ let unframed = 0;
31
+ for (const entry of evidence) {
32
+ if (entry.frame === null) {
33
+ unframed++;
34
+ continue;
35
+ }
36
+ const earlier = frames.get(entry.frame);
37
+ if (earlier === undefined ||
38
+ (entry.elapsedMs !== null && (earlier ?? Infinity) > entry.elapsedMs))
39
+ frames.set(entry.frame, entry.elapsedMs);
40
+ }
41
+ const parts = [...frames]
42
+ .sort(([a], [b]) => a - b)
43
+ .map(([frame, ms]) => `frame ${frame}${ms === null ? "" : ` at ${elapsedText(ms)}`}`);
44
+ if (unframed > 0)
45
+ parts.push(`${plural(unframed, "item")} with no frame`);
46
+ return parts.join(", ");
47
+ }
48
+ /** The capture files, relative to the run directory, in frame order. */
49
+ function captureFiles(evidence, runRoot) {
50
+ return [
51
+ ...new Set(evidence
52
+ .filter((entry) => entry.capture !== null)
53
+ .sort((a, b) => (a.frame ?? 0) - (b.frame ?? 0))
54
+ .map((entry) => (runRoot ? path.relative(runRoot, entry.capture) : entry.capture))),
55
+ ];
56
+ }
57
+ function findingLines(finding, runRoot) {
58
+ const cited = [...new Set(finding.evidence.map((entry) => entry.streamId))];
59
+ const labels = new Map(finding.affected.map((p) => [p.streamId, p.label]));
60
+ const affectedIds = finding.affected.map((p) => p.streamId);
61
+ const streams = [...affectedIds, ...cited.filter((id) => !labels.has(id))];
62
+ const captures = captureFiles(finding.evidence, runRoot);
63
+ const correction = finding.correction;
64
+ const lead = findingLead(finding, correction);
65
+ return [
66
+ ...(lead === null
67
+ ? [`${finding.id} ${finding.title}`]
68
+ : [
69
+ `${finding.id} ${lead.headline}`,
70
+ ...(lead.account === null ? [] : [` ${lead.account}`]),
71
+ ` evidence: ${finding.title}`,
72
+ ]),
73
+ ` impact: ${IMPACT_TEXT[finding.impact]} · confidence: ${finding.confidence} · recovery: ${RECOVERY_TEXT[finding.recovery]}`,
74
+ ` ${finding.summary}`,
75
+ ` affected: ${finding.affected.length} of ${plural(finding.exposedCount, "exposed participant")}`,
76
+ ...streams.map((streamId) => {
77
+ const moments = momentsText(finding.evidence.filter((entry) => entry.streamId === streamId));
78
+ const label = `${labels.get(streamId) ?? streamId}${labels.has(streamId) ? "" : " (cited, not affected)"}`;
79
+ return ` - ${label}${moments ? `: ${moments}` : ""}`;
80
+ }),
81
+ ...(captures.length === 0 ? [] : [` captures: ${captures.join(", ")}`]),
82
+ ` next step: ${finding.nextStep}`,
83
+ ...(correction === null
84
+ ? []
85
+ : [
86
+ ` human review: ${correction.status}, ${correction.reason}${correction.replacementClaim ? ` Amended claim: ${correction.replacementClaim}` : ""}`,
87
+ ]),
88
+ ];
89
+ }
90
+ function designFindingLines(finding, runRoot) {
91
+ const captures = captureFiles(finding.evidence, runRoot);
92
+ return [
93
+ `${finding.id} ${finding.severity}: ${finding.headline}`,
94
+ ` screen: ${finding.screen}`,
95
+ ` notice: ${finding.notice}`,
96
+ ` why it matters: ${finding.whyItMatters}`,
97
+ ` suggestion: ${finding.suggestion}`,
98
+ ` seen by: ${finding.seenBy.map((p) => p.label).join(", ")} · confidence: ${finding.confidence}`,
99
+ ...(captures.length === 0 ? [] : [` captures: ${captures.join(", ")}`]),
100
+ ];
101
+ }
102
+ /** Nothing for an analysis written before design findings. */
103
+ function designSection(view) {
104
+ const design = view.designFindings;
105
+ if (design === null)
106
+ return [];
107
+ if (design.length === 0)
108
+ return ["", "design findings: none in the reviewed captures"];
109
+ return [
110
+ "",
111
+ `design findings: ${design.length}, most severe first`,
112
+ ...design.flatMap((finding, index) => [
113
+ ...(index === 0 ? [] : [""]),
114
+ ...designFindingLines(finding, view.runPath ?? ""),
115
+ ]),
116
+ ];
117
+ }
118
+ /** The full findings block: the analysis, every finding with its evidence, and its limitations. */
119
+ export function formatFindings(view) {
120
+ if (view.state !== "ready" && view.state !== "stale")
121
+ return [`findings: none. ${view.message}`, ...(view.next ? [`next: ${view.next}`] : [])];
122
+ const model = [view.provider, view.model].filter(Boolean).join(" ");
123
+ const cost = view.estimatedCostUsd === null ? "" : `, estimated $${view.estimatedCostUsd.toFixed(2)}`;
124
+ return [
125
+ `findings: ${view.findings.length} from analysis ${view.analysisId} (${view.status}, ${model}${cost})`,
126
+ ...(view.state === "stale" ? [`warning: ${view.message}`, `next: ${view.next}`] : []),
127
+ ...(view.summary ? [view.summary] : []),
128
+ ...view.findings.flatMap((finding) => ["", ...findingLines(finding, view.runPath ?? "")]),
129
+ ...designSection(view),
130
+ ...(view.limitations.length === 0
131
+ ? []
132
+ : ["", "limitations:", ...view.limitations.map((limitation) => `- ${limitation}`)]),
133
+ ...view.warnings.map((warning) => `warning: ${warning}`),
134
+ "",
135
+ `analysis: ${view.path}`,
136
+ ];
137
+ }
138
+ /** At most `limit` findings, one line each, and the command that prints all of them. */
139
+ export function formatFindingsSummary(view, fullCommand, limit = 3) {
140
+ if (view.state !== "ready" && view.state !== "stale")
141
+ return [`findings: ${view.message}`, ...(view.next ? [`next: ${view.next}`] : [])];
142
+ const shown = view.findings.slice(0, limit);
143
+ const design = view.designFindings;
144
+ return [
145
+ `findings: ${view.findings.length === 0 ? "none" : view.findings.length}${view.state === "stale" ? " (stale)" : ""}`,
146
+ ...shown.map((finding) => {
147
+ const qualities = `${IMPACT_TEXT[finding.impact]}, ${finding.confidence} confidence, ${RECOVERY_TEXT[finding.recovery]}`;
148
+ const lead = findingLead(finding, finding.correction);
149
+ if (lead === null)
150
+ return `- ${finding.id} ${qualities}: ${finding.title}`;
151
+ return `- ${finding.id} ${lead.headline} (${lead.corrected ? "corrected in human review" : qualities})`;
152
+ }),
153
+ ...(view.findings.length > limit
154
+ ? [`- and ${plural(view.findings.length - limit, "more finding")}`]
155
+ : []),
156
+ ...(design === null
157
+ ? []
158
+ : [
159
+ `design findings: ${design.length === 0 ? "none" : design.length}`,
160
+ ...design
161
+ .slice(0, limit)
162
+ .map((finding) => `- ${finding.id} ${finding.severity}: ${finding.headline}`),
163
+ ...(design.length > limit
164
+ ? [`- and ${plural(design.length - limit, "more design finding")}`]
165
+ : []),
166
+ ]),
167
+ `all findings: ${fullCommand}`,
168
+ ];
169
+ }
@@ -137,10 +137,6 @@ export declare function readRunAnalysis(cwd: string, run: string, analysisId?: s
137
137
  export declare function missingRunFindings(cwd: string, run: string): AnalysisFindings;
138
138
  /** The findings of a run's selected analysis, or why it has none. */
139
139
  export declare function readRunFindings(cwd: string, run: string, analysisId?: string): Promise<AnalysisFindings>;
140
- /** The full findings block: the analysis, every finding with its evidence, and its limitations. */
141
- export declare function formatFindings(view: AnalysisFindings): string[];
142
- /** At most `limit` findings, one line each, and the command that prints all of them. */
143
- export declare function formatFindingsSummary(view: AnalysisFindings, fullCommand: string, limit?: number): string[];
144
140
  /**
145
141
  * The end of a live run's human output, after its `analysis:` line: the findings of the analysis
146
142
  * the run just completed. Prints nothing for --json, or when the run's analysis did not complete,
@@ -1,9 +1,11 @@
1
1
  // A run's analysis findings for `review`, `analyze show` and the end of a live `run`: the
2
- // `humanish.analysis-findings.v1` projection and its text. The analysis artifact stays the record;
3
- // this reads the version `analyze show` selects and never starts an analysis.
2
+ // `humanish.analysis-findings.v1` projection, read from the run. findings-text.ts prints it. The
3
+ // analysis artifact stays the record; this reads the version `analyze show` selects and never
4
+ // starts an analysis.
4
5
  import path from "node:path";
5
- import { admittingMaxCost, costRefusalText } from "../analysis/admission.js";
6
+ import { costRefusal } from "../analysis/admission.js";
6
7
  import { DEFAULT_ANALYSIS_MAX_COST_USD } from "../analysis/automatic-config.js";
8
+ import { bySeverity } from "../analysis/finding-lead.js";
7
9
  import { loadAnalysis } from "../analysis/load.js";
8
10
  import { projectShareCheckedAnalysis } from "../analysis/sharing.js";
9
11
  import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
@@ -14,6 +16,7 @@ import { routeOf } from "../study/routing.js";
14
16
  import { plural } from "../run/text.js";
15
17
  import { shellQuote } from "../substrates/shell.js";
16
18
  import { analysisOutcomeText, wantsJson } from "./io.js";
19
+ import { formatFindingsSummary } from "./findings-text.js";
17
20
  import { cli } from "./invocation.js";
18
21
  export const ANALYSIS_FINDINGS_SCHEMA = "humanish.analysis-findings.v1";
19
22
  /** ` --cwd <dir>`, or "" when `cwd` is the current directory. */
@@ -64,20 +67,13 @@ function evidenceOf(finding, analysis, runRoot) {
64
67
  ...entry,
65
68
  }));
66
69
  }
67
- const SEVERITY_RANK = {
68
- major: 0,
69
- moderate: 1,
70
- minor: 2,
71
- };
72
70
  /** Most severe first; equal severity keeps the analysis order. */
73
71
  function designFindingViews(analysis, runRoot) {
74
72
  const designFindings = analysis.result?.designFindings;
75
73
  if (designFindings === undefined)
76
74
  return null;
77
75
  const labels = new Map(analysis.participants.map((p) => [p.streamId, p.label]));
78
- return [...designFindings]
79
- .sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])
80
- .map((finding) => ({
76
+ return bySeverity(designFindings).map((finding) => ({
81
77
  id: finding.id,
82
78
  headline: finding.headline,
83
79
  screen: finding.screen,
@@ -200,8 +196,10 @@ export function analysisFindings(source) {
200
196
  const outcome = analysisOutcomeText(job);
201
197
  return withoutFindings(source, "failed", job.reason, `The automatic analysis ${outcome.startsWith("failed") ? "" : "was "}${outcome}.`, analyzeCommand(source, job.reason === "AUTOMATIC_ANALYSIS_CODEX_UNAVAILABLE" ? "codex" : undefined));
202
198
  }
203
- if (job?.state === "skipped" && job.admission)
204
- return withoutFindings(source, "skipped", job.reason, `The automatic analysis was refused before it started. ${costRefusalText(job.admission)}`, cli(`analyze --run ${source.runId}${source.cwdFlag} --max-cost ${admittingMaxCost(job.admission)}`));
199
+ if (job?.state === "skipped" && job.admission) {
200
+ const refusal = costRefusal(job.admission, `--run ${source.runId}${source.cwdFlag}`, cli);
201
+ return withoutFindings(source, "skipped", job.reason, `The automatic analysis was refused before it started. ${refusal.text}`, refusal.command);
202
+ }
205
203
  if (job?.state === "skipped") {
206
204
  const refused = job.reason === "AUTOMATIC_ANALYSIS_ADMISSION_REFUSED";
207
205
  return withoutFindings(source, "skipped", job.reason, `The automatic analysis was ${analysisOutcomeText(job)}.${refused ? " The dry run says why, and for a cost refusal names the --max-cost that admits it." : ""}`, job.reason === "AUTOMATIC_ANALYSIS_NO_PARTICIPANT_EVIDENCE"
@@ -291,188 +289,6 @@ export async function readRunFindings(cwd, run, analysisId) {
291
289
  const source = await readRunAnalysis(cwd, run, analysisId);
292
290
  return source ? analysisFindings(source) : missingRunFindings(cwd, run);
293
291
  }
294
- const IMPACT_TEXT = {
295
- blocked_task: "blocked task",
296
- friction: "friction",
297
- recovery: "recovery",
298
- uncertain: "uncertain",
299
- };
300
- const RECOVERY_TEXT = {
301
- recovered: "recovered",
302
- not_observed: "no recovery observed",
303
- unknown: "recovery unknown",
304
- };
305
- /** `+1:05` from milliseconds since the first retained capture. */
306
- function elapsedText(ms) {
307
- const seconds = Math.floor(ms / 1000);
308
- const minutes = Math.floor(seconds / 60);
309
- const hours = Math.floor(minutes / 60);
310
- const ss = String(seconds % 60).padStart(2, "0");
311
- return hours > 0
312
- ? `+${hours}:${String(minutes % 60).padStart(2, "0")}:${ss}`
313
- : `+${minutes}:${ss}`;
314
- }
315
- /** One participant's cited moments: each frame once, at its earliest cited time. */
316
- function momentsText(evidence) {
317
- const frames = new Map();
318
- let unframed = 0;
319
- for (const entry of evidence) {
320
- if (entry.frame === null) {
321
- unframed++;
322
- continue;
323
- }
324
- const earlier = frames.get(entry.frame);
325
- if (earlier === undefined ||
326
- (entry.elapsedMs !== null && (earlier ?? Infinity) > entry.elapsedMs))
327
- frames.set(entry.frame, entry.elapsedMs);
328
- }
329
- const parts = [...frames]
330
- .sort(([a], [b]) => a - b)
331
- .map(([frame, ms]) => `frame ${frame}${ms === null ? "" : ` at ${elapsedText(ms)}`}`);
332
- if (unframed > 0)
333
- parts.push(`${plural(unframed, "item")} with no frame`);
334
- return parts.join(", ");
335
- }
336
- /** The capture files, relative to the run directory, in frame order. */
337
- function captureFiles(evidence, runRoot) {
338
- return [
339
- ...new Set(evidence
340
- .filter((entry) => entry.capture !== null)
341
- .sort((a, b) => (a.frame ?? 0) - (b.frame ?? 0))
342
- .map((entry) => (runRoot ? path.relative(runRoot, entry.capture) : entry.capture))),
343
- ];
344
- }
345
- /**
346
- * What a reader sees first: the headline, or the reviewer's claim when a review amended the finding,
347
- * since the headline and experience describe the claim it replaced. Null before headlines existed.
348
- */
349
- function leadOf(finding) {
350
- const amended = finding.correction?.status === "amended" ? finding.correction : null;
351
- if (finding.headline === null)
352
- return null;
353
- return amended?.replacementClaim
354
- ? { headline: amended.replacementClaim, corrected: true }
355
- : { headline: finding.headline, corrected: false };
356
- }
357
- function findingLines(finding, runRoot) {
358
- const cited = [...new Set(finding.evidence.map((entry) => entry.streamId))];
359
- const labels = new Map(finding.affected.map((p) => [p.streamId, p.label]));
360
- const affectedIds = finding.affected.map((p) => p.streamId);
361
- const streams = [...affectedIds, ...cited.filter((id) => !labels.has(id))];
362
- const captures = captureFiles(finding.evidence, runRoot);
363
- const correction = finding.correction;
364
- const lead = leadOf(finding);
365
- return [
366
- ...(lead === null
367
- ? [`${finding.id} ${finding.title}`]
368
- : [
369
- `${finding.id} ${lead.headline}`,
370
- ...(lead.corrected
371
- ? [
372
- " Corrected in human review. The reviewer's claim replaces the original headline and account.",
373
- ]
374
- : finding.experience === null
375
- ? []
376
- : [` ${finding.experience}`]),
377
- ` evidence: ${finding.title}`,
378
- ]),
379
- ` impact: ${IMPACT_TEXT[finding.impact]} · confidence: ${finding.confidence} · recovery: ${RECOVERY_TEXT[finding.recovery]}`,
380
- ` ${finding.summary}`,
381
- ` affected: ${finding.affected.length} of ${plural(finding.exposedCount, "exposed participant")}`,
382
- ...streams.map((streamId) => {
383
- const moments = momentsText(finding.evidence.filter((entry) => entry.streamId === streamId));
384
- const label = `${labels.get(streamId) ?? streamId}${labels.has(streamId) ? "" : " (cited, not affected)"}`;
385
- return ` - ${label}${moments ? `: ${moments}` : ""}`;
386
- }),
387
- ...(captures.length === 0 ? [] : [` captures: ${captures.join(", ")}`]),
388
- ` next step: ${finding.nextStep}`,
389
- ...(correction === null
390
- ? []
391
- : [
392
- ` human review: ${correction.status}, ${correction.reason}${correction.replacementClaim ? ` Amended claim: ${correction.replacementClaim}` : ""}`,
393
- ]),
394
- ];
395
- }
396
- function designFindingLines(finding, runRoot) {
397
- const captures = captureFiles(finding.evidence, runRoot);
398
- return [
399
- `${finding.id} ${finding.severity}: ${finding.headline}`,
400
- ` screen: ${finding.screen}`,
401
- ` notice: ${finding.notice}`,
402
- ` why it matters: ${finding.whyItMatters}`,
403
- ` suggestion: ${finding.suggestion}`,
404
- ` seen by: ${finding.seenBy.map((p) => p.label).join(", ")} · confidence: ${finding.confidence}`,
405
- ...(captures.length === 0 ? [] : [` captures: ${captures.join(", ")}`]),
406
- ];
407
- }
408
- /** Nothing for an analysis written before design findings. */
409
- function designSection(view) {
410
- const design = view.designFindings;
411
- if (design === null)
412
- return [];
413
- if (design.length === 0)
414
- return ["", "design findings: none in the reviewed captures"];
415
- return [
416
- "",
417
- `design findings: ${design.length}, most severe first`,
418
- ...design.flatMap((finding, index) => [
419
- ...(index === 0 ? [] : [""]),
420
- ...designFindingLines(finding, view.runPath ?? ""),
421
- ]),
422
- ];
423
- }
424
- /** The full findings block: the analysis, every finding with its evidence, and its limitations. */
425
- export function formatFindings(view) {
426
- if (view.state !== "ready" && view.state !== "stale")
427
- return [`findings: none. ${view.message}`, ...(view.next ? [`next: ${view.next}`] : [])];
428
- const model = [view.provider, view.model].filter(Boolean).join(" ");
429
- const cost = view.estimatedCostUsd === null ? "" : `, estimated $${view.estimatedCostUsd.toFixed(2)}`;
430
- return [
431
- `findings: ${view.findings.length} from analysis ${view.analysisId} (${view.status}, ${model}${cost})`,
432
- ...(view.state === "stale" ? [`warning: ${view.message}`, `next: ${view.next}`] : []),
433
- ...(view.summary ? [view.summary] : []),
434
- ...view.findings.flatMap((finding) => ["", ...findingLines(finding, view.runPath ?? "")]),
435
- ...designSection(view),
436
- ...(view.limitations.length === 0
437
- ? []
438
- : ["", "limitations:", ...view.limitations.map((limitation) => `- ${limitation}`)]),
439
- ...view.warnings.map((warning) => `warning: ${warning}`),
440
- "",
441
- `analysis: ${view.path}`,
442
- ];
443
- }
444
- /** At most `limit` findings, one line each, and the command that prints all of them. */
445
- export function formatFindingsSummary(view, fullCommand, limit = 3) {
446
- if (view.state !== "ready" && view.state !== "stale")
447
- return [`findings: ${view.message}`, ...(view.next ? [`next: ${view.next}`] : [])];
448
- const shown = view.findings.slice(0, limit);
449
- const design = view.designFindings;
450
- return [
451
- `findings: ${view.findings.length === 0 ? "none" : view.findings.length}${view.state === "stale" ? " (stale)" : ""}`,
452
- ...shown.map((finding) => {
453
- const qualities = `${IMPACT_TEXT[finding.impact]}, ${finding.confidence} confidence, ${RECOVERY_TEXT[finding.recovery]}`;
454
- const lead = leadOf(finding);
455
- if (lead === null)
456
- return `- ${finding.id} ${qualities}: ${finding.title}`;
457
- return `- ${finding.id} ${lead.headline} (${lead.corrected ? "corrected in human review" : qualities})`;
458
- }),
459
- ...(view.findings.length > limit
460
- ? [`- and ${plural(view.findings.length - limit, "more finding")}`]
461
- : []),
462
- ...(design === null
463
- ? []
464
- : [
465
- `design findings: ${design.length === 0 ? "none" : design.length}`,
466
- ...design
467
- .slice(0, limit)
468
- .map((finding) => `- ${finding.id} ${finding.severity}: ${finding.headline}`),
469
- ...(design.length > limit
470
- ? [`- and ${plural(design.length - limit, "more design finding")}`]
471
- : []),
472
- ]),
473
- `all findings: ${fullCommand}`,
474
- ];
475
- }
476
292
  /**
477
293
  * The end of a live run's human output, after its `analysis:` line: the findings of the analysis
478
294
  * the run just completed. Prints nothing for --json, or when the run's analysis did not complete,
package/dist/cli/io.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { automaticAnalysisSucceeded, } from "../analysis/automatic-completion.js";
2
- import { admittingMaxCost, costRefusalText, refusedCost, } from "../analysis/admission.js";
2
+ import { costRefusal, refusedCost } from "../analysis/admission.js";
3
3
  import { Command, Option } from "commander";
4
4
  import { loadEnvFile, recordDotenvNames } from "../keys/env-file.js";
5
5
  import { discoverProviderKeys } from "../keys/key-resolution.js";
@@ -186,11 +186,17 @@ export function writeResult(command, io, result, formatHuman) {
186
186
  }
187
187
  else {
188
188
  writeHuman(command, io, formatHuman(output));
189
- if (output !== null && typeof output === "object" && "automaticAnalysis" in output) {
189
+ // A run refused before it was created has no analysis to report, so its output ends with the
190
+ // refusal. Its JSON keeps the skip record.
191
+ if (output !== null &&
192
+ typeof output === "object" &&
193
+ "automaticAnalysis" in output &&
194
+ output.runId !== "not-created") {
190
195
  const analysis = output.automaticAnalysis;
191
196
  const cost = analysis?.result?.admission && refusedCost(analysis.result.admission);
192
- if (cost)
193
- io.writeOut(`analysis: refused before it started. ${refusedAnalysisText(cost, analysis.result?.run ?? "latest")}\n`);
197
+ const refusal = cost && costRefusal(cost, `--run ${analysis.result?.run ?? "latest"}`, cli);
198
+ if (refusal)
199
+ io.writeOut(`analysis: refused before it started. ${refusal.text} ${refusal.command}\n`);
194
200
  else if (analysis)
195
201
  io.writeOut(`analysis: ${analysisOutcomeText(analysis)}\n`);
196
202
  const rejected = analysis?.result?.rejectedOutputPath;
@@ -306,10 +312,6 @@ export function analysisOutcomeText(analysis) {
306
312
  return analysis.state;
307
313
  return ANALYSIS_REASON_TEXT[analysis.reason] ?? `${analysis.state} (${analysis.reason})`;
308
314
  }
309
- /** What a refused analysis would cost and the command that runs it with a cap that admits it. */
310
- function refusedAnalysisText(cost, runId) {
311
- return `${costRefusalText(cost)} To run it, raise the cap: ${cli(`analyze --run ${runId} --max-cost ${admittingMaxCost(cost)}`)}`;
312
- }
313
315
  /** Preserve the run's own result while making requested post-processing failures machine-visible. */
314
316
  export function automaticAnalysisEnvelope(result) {
315
317
  if (result === null ||
@@ -12,9 +12,9 @@
12
12
  import { isCuaDiagnosticCategory, isCuaDiagnosticStopCause, } from "../routes/computer-use/diagnostics.js";
13
13
  import { randomUUID } from "node:crypto";
14
14
  import { mkdir, readFile, writeFile } from "node:fs/promises";
15
- import { homedir } from "node:os";
16
15
  import path from "node:path";
17
16
  import { STUDY_RESULT_SCHEMA } from "../run/study-result.js";
17
+ import { envFlag, humanishConfigFile } from "./user-config.js";
18
18
  /** Write-only PostHog project key. Public by design: it can ingest, and can read nothing. */
19
19
  const INGEST_KEY = "phc_oeMeBqxDZhZ9tCHMSnuDFimLqHpU5Myc847WD33hAh4C";
20
20
  const INGEST_HOST = "https://us.i.posthog.com";
@@ -26,26 +26,16 @@ const STARTER_STUDIES = new Set([
26
26
  "local-browser",
27
27
  "lobby-trivia-3player",
28
28
  ]);
29
- export function telemetryStatePath(env = process.env, home = homedir()) {
30
- const declared = env.XDG_CONFIG_HOME?.trim();
31
- // Same XDG rule the key store uses: a relative value must be ignored, or state becomes
32
- // cwd-relative and follows people between projects.
33
- const configHome = declared !== undefined && declared !== "" && path.isAbsolute(declared)
34
- ? declared
35
- : path.join(home, ".config");
36
- return path.join(configHome, "humanish", "telemetry.json");
29
+ export function telemetryStatePath(env = process.env, home) {
30
+ return humanishConfigFile(env, "telemetry.json", home);
37
31
  }
38
32
  /**
39
33
  * Off means off, from any of the three switches people already expect: the cross-tool
40
34
  * `DO_NOT_TRACK` standard, our own env var, and the persisted opt-out.
41
35
  */
42
36
  export function disabledByEnvironment(env) {
43
- const truthy = (value) => value !== undefined &&
44
- value.trim() !== "" &&
45
- value.trim() !== "0" &&
46
- value.trim().toLowerCase() !== "false";
47
- return (truthy(env.DO_NOT_TRACK) ||
48
- truthy(env.HUMANISH_TELEMETRY_DISABLED) ||
37
+ return (envFlag(env.DO_NOT_TRACK) ||
38
+ envFlag(env.HUMANISH_TELEMETRY_DISABLED) ||
49
39
  // Our own development and test runs must never reach the adoption dataset. In the first two
50
40
  // days after telemetry shipped, 82% of events (4,042 of 4,932, from 49 of 59 anonymous ids)
51
41
  // came from humanish's own CI and suite: ~50 ids each running nearly every subcommand about
@@ -55,7 +45,7 @@ export function disabledByEnvironment(env) {
55
45
  // Deliberately not keyed on CI. An adopter running humanish in their pipeline is real usage
56
46
  // and stays countable; the `ci` property already separates it, which is what Next.js does.
57
47
  // This keys on being inside the humanish source tree, which only we ever are.
58
- truthy(env.HUMANISH_DEV));
48
+ envFlag(env.HUMANISH_DEV));
59
49
  }
60
50
  /**
61
51
  * True when this process is running from a humanish source checkout rather than an install.
@@ -101,7 +91,7 @@ export function inHumanishCheckout(startDir, readFileSyncFn) {
101
91
  export function isOwnCheckoutRun(cwd, cliDir, readFileSyncFn) {
102
92
  return inHumanishCheckout(cwd, readFileSyncFn) || inHumanishCheckout(cliDir, readFileSyncFn);
103
93
  }
104
- export async function readTelemetryState(env = process.env, home = homedir()) {
94
+ export async function readTelemetryState(env = process.env, home) {
105
95
  try {
106
96
  const raw = await readFile(telemetryStatePath(env, home), "utf8");
107
97
  const parsed = JSON.parse(raw);
@@ -115,7 +105,7 @@ export async function readTelemetryState(env = process.env, home = homedir()) {
115
105
  return { enabled: true, anonymousId: randomUUID(), noticed: false };
116
106
  }
117
107
  }
118
- export async function writeTelemetryState(state, env = process.env, home = homedir()) {
108
+ export async function writeTelemetryState(state, env = process.env, home) {
119
109
  const file = telemetryStatePath(env, home);
120
110
  await mkdir(path.dirname(file), { recursive: true });
121
111
  await writeFile(file, `${JSON.stringify(state, null, 2)}\n`, "utf8");
@@ -162,15 +152,13 @@ export function buildPayload(args) {
162
152
  version: args.version,
163
153
  os: args.platform ?? process.platform,
164
154
  node: (args.nodeVersion ?? process.version).split(".")[0].replace("v", ""),
165
- ci: env.CI !== undefined && env.CI !== "" && env.CI !== "0",
155
+ ci: envFlag(env.CI),
166
156
  // A humanish study participant is a real cold install on a machine we do not own, so it emits
167
157
  // like any other new adopter and is indistinguishable from one. It is also the exact
168
158
  // population we are trying to count, so a busy self-study day reads as an adoption spike.
169
159
  // Stamped rather than suppressed, the same shape as `ci`: participant runs stay visible and
170
160
  // stay separable, and we keep a genuine measurement of what a cold install does.
171
- studyParticipant: env.HUMANISH_STUDY_PARTICIPANT !== undefined &&
172
- env.HUMANISH_STUDY_PARTICIPANT !== "" &&
173
- env.HUMANISH_STUDY_PARTICIPANT !== "0",
161
+ studyParticipant: envFlag(env.HUMANISH_STUDY_PARTICIPANT),
174
162
  };
175
163
  const given = args.properties ?? {};
176
164
  if (given.command !== undefined)
@@ -4,20 +4,12 @@
4
4
  // the command that started it.
5
5
  import { spawn } from "node:child_process";
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
- import { homedir } from "node:os";
8
7
  import path from "node:path";
9
8
  import { fileURLToPath } from "node:url";
10
9
  import { disabledByEnvironment } from "./telemetry.js";
10
+ import { envFlag, humanishConfigFile } from "./user-config.js";
11
11
  const DAY_MS = 24 * 60 * 60 * 1000;
12
12
  const REGISTRY_DIST_TAGS = "https://registry.npmjs.org/-/package/humanish/dist-tags";
13
- /** Next to telemetry.json, under the same XDG rule the key store uses. */
14
- function updateCachePath(env, home = homedir()) {
15
- const declared = env.XDG_CONFIG_HOME?.trim();
16
- const configHome = declared !== undefined && declared !== "" && path.isAbsolute(declared)
17
- ? declared
18
- : path.join(home, ".config");
19
- return path.join(configHome, "humanish", "update-check.json");
20
- }
21
13
  function readUpdateCache(cachePath) {
22
14
  try {
23
15
  const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
@@ -65,24 +57,23 @@ function dayPassed(timestamp, now) {
65
57
  const then = timestamp === undefined ? Number.NaN : Date.parse(timestamp);
66
58
  return Number.isNaN(then) || now - then >= DAY_MS;
67
59
  }
68
- const truthy = (value) => value !== undefined &&
69
- value.trim() !== "" &&
70
- value.trim() !== "0" &&
71
- value.trim().toLowerCase() !== "false";
72
60
  /**
73
61
  * Only a person at a terminal is told, and only when nothing has switched the check off. A pipe,
74
62
  * CI and a JSON document have a program reading them. A study participant runs the version the
75
63
  * study chose, and a notice would send it to another. The telemetry switches turn this off too:
76
- * someone who opted out of telemetry expects no unprompted requests.
64
+ * someone who opted out of telemetry expects no unprompted requests. NO_UPDATE_NOTIFIER is the
65
+ * switch the update-notifier package reads, and it is read the same way, so any value, even `0`,
66
+ * turns the check off for someone who set it for the CLIs built on that package.
77
67
  */
78
68
  function checkAllowed(context) {
79
69
  const env = context.env;
80
70
  return (context.terminal &&
81
71
  !context.json &&
82
72
  !context.ownCheckout &&
83
- !truthy(env.CI) &&
84
- !truthy(env.HUMANISH_NO_UPDATE_CHECK) &&
85
- !truthy(env.HUMANISH_STUDY_PARTICIPANT) &&
73
+ !envFlag(env.CI) &&
74
+ !envFlag(env.HUMANISH_NO_UPDATE_CHECK) &&
75
+ env.NO_UPDATE_NOTIFIER === undefined &&
76
+ !envFlag(env.HUMANISH_STUDY_PARTICIPANT) &&
86
77
  !disabledByEnvironment(env));
87
78
  }
88
79
  /**
@@ -94,7 +85,7 @@ export function checkForUpdate(context) {
94
85
  try {
95
86
  if (!checkAllowed(context))
96
87
  return undefined;
97
- const cachePath = updateCachePath(context.env, context.home);
88
+ const cachePath = humanishConfigFile(context.env, "update-check.json", context.home);
98
89
  const cache = readUpdateCache(cachePath);
99
90
  const stamp = new Date(context.now).toISOString();
100
91
  const next = { ...cache };
@@ -126,7 +117,7 @@ export function checkForUpdate(context) {
126
117
  * cache only; undefined until a check has succeeded on this machine.
127
118
  */
128
119
  export function recordedVersion(installed, env, home) {
129
- const cache = readUpdateCache(updateCachePath(env, home));
120
+ const cache = readUpdateCache(humanishConfigFile(env, "update-check.json", home));
130
121
  if (cache.latest === undefined)
131
122
  return undefined;
132
123
  const checked = cache.checkedAt === undefined ? "" : ` (checked ${cache.checkedAt.slice(0, 10)})`;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `$XDG_CONFIG_HOME/humanish/<fileName>`, or `<home>/.config/humanish/<fileName>`. The XDG spec
3
+ * says to ignore a relative XDG_CONFIG_HOME. Honoring one would put the file in whichever project
4
+ * is open, and for the key store that writes a secret into the repo.
5
+ */
6
+ export declare function humanishConfigFile(env: NodeJS.ProcessEnv, fileName: string, home?: string): string;
7
+ /**
8
+ * Whether a flag variable such as `CI` or `DO_NOT_TRACK` is on. Unset, blank, `0` and `false` (any
9
+ * case, surrounding spaces ignored) are off, as is-in-ci reads `CI`; any other value is on.
10
+ */
11
+ export declare function envFlag(value: string | undefined): boolean;
@@ -0,0 +1,25 @@
1
+ // Where humanish keeps per-user files (the key store, telemetry state, the update-check cache) and
2
+ // how it reads an on/off environment variable. The key store, telemetry and the update check each
3
+ // had their own copy of both.
4
+ import { homedir } from "node:os";
5
+ import path from "node:path";
6
+ /**
7
+ * `$XDG_CONFIG_HOME/humanish/<fileName>`, or `<home>/.config/humanish/<fileName>`. The XDG spec
8
+ * says to ignore a relative XDG_CONFIG_HOME. Honoring one would put the file in whichever project
9
+ * is open, and for the key store that writes a secret into the repo.
10
+ */
11
+ export function humanishConfigFile(env, fileName, home = homedir()) {
12
+ const declared = env.XDG_CONFIG_HOME?.trim();
13
+ const configHome = declared !== undefined && declared !== "" && path.isAbsolute(declared)
14
+ ? declared
15
+ : path.join(home, ".config");
16
+ return path.join(configHome, "humanish", fileName);
17
+ }
18
+ /**
19
+ * Whether a flag variable such as `CI` or `DO_NOT_TRACK` is on. Unset, blank, `0` and `false` (any
20
+ * case, surrounding spaces ignored) are off, as is-in-ci reads `CI`; any other value is on.
21
+ */
22
+ export function envFlag(value) {
23
+ const trimmed = value?.trim() ?? "";
24
+ return trimmed !== "" && trimmed !== "0" && trimmed.toLowerCase() !== "false";
25
+ }