ultimate-pi 0.19.0 → 0.19.1

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 (60) hide show
  1. package/.agents/skills/web-retrieval/SKILL.md +163 -0
  2. package/.agents/skills/wiki-autoresearch/SKILL.md +6 -6
  3. package/.pi/SYSTEM.md +30 -12
  4. package/.pi/agents/harness/planning/implementation-researcher.md +1 -1
  5. package/.pi/agents/harness/planning/stack-researcher.md +5 -1
  6. package/.pi/agents/harness/web-retrieval/web-answerer.md +35 -0
  7. package/.pi/agents/harness/web-retrieval/web-criteria-verifier.md +28 -0
  8. package/.pi/agents/harness/web-retrieval/web-gap-analyzer.md +31 -0
  9. package/.pi/agents/harness/web-retrieval/web-query-expander-fast.md +34 -0
  10. package/.pi/agents/harness/web-retrieval/web-query-expander.md +60 -0
  11. package/.pi/agents/harness/web-retrieval/web-summarizer.md +18 -0
  12. package/.pi/extensions/harness-web-guard.ts +2 -1
  13. package/.pi/extensions/harness-web-tools.ts +689 -51
  14. package/.pi/harness/agents.manifest.json +29 -5
  15. package/.pi/harness/agents.policy.yaml +34 -0
  16. package/.pi/harness/docs/adrs/0050-agentic-web-retrieval-stack.md +46 -0
  17. package/.pi/harness/docs/harness-web-search.md +97 -0
  18. package/.pi/harness/env.harness.template +9 -1
  19. package/.pi/harness/examples/web-heuristic-angles.project.yaml +22 -0
  20. package/.pi/harness/web-heuristic-angles.json +278 -0
  21. package/.pi/harness/web-heuristic-angles.yaml +182 -0
  22. package/.pi/lib/agents-policy.mjs +6 -0
  23. package/.pi/lib/harness-subagent-auth.ts +39 -9
  24. package/.pi/lib/harness-subagents-bridge.ts +21 -0
  25. package/.pi/lib/harness-web/artifacts.ts +200 -0
  26. package/.pi/lib/harness-web/cache.ts +369 -0
  27. package/.pi/lib/harness-web/run-cli.ts +42 -2
  28. package/.pi/prompts/harness-plan.md +1 -0
  29. package/.pi/prompts/harness-setup.md +3 -1
  30. package/.pi/scripts/gen-web-heuristic-angles-json.mjs +24 -0
  31. package/.pi/scripts/harness-cli-verify.sh +5 -0
  32. package/.pi/scripts/harness-verify.mjs +78 -0
  33. package/.pi/scripts/harness-web-policy-guard.mjs +1 -1
  34. package/.pi/scripts/harness-web.py +218 -15
  35. package/.pi/scripts/harness_web/deep_search.py +55 -0
  36. package/.pi/scripts/harness_web/evidence_bundle.py +47 -0
  37. package/.pi/scripts/harness_web/find_similar.py +88 -0
  38. package/.pi/scripts/harness_web/heuristic_angles_shipped.py +85 -0
  39. package/.pi/scripts/harness_web/heuristic_config.py +251 -0
  40. package/.pi/scripts/harness_web/highlights.py +47 -0
  41. package/.pi/scripts/harness_web/multi_search.py +59 -0
  42. package/.pi/scripts/harness_web/output.py +24 -0
  43. package/.pi/scripts/harness_web/query_angles.py +116 -0
  44. package/.pi/scripts/harness_web/rank.py +163 -0
  45. package/.pi/scripts/harness_web/scrape.py +30 -0
  46. package/.pi/scripts/tests/test_harness_web_heuristic_config.py +132 -0
  47. package/.pi/scripts/tests/test_harness_web_query_angles.py +45 -0
  48. package/.pi/scripts/tests/test_harness_web_rank.py +56 -0
  49. package/AGENTS.md +2 -2
  50. package/CHANGELOG.md +6 -0
  51. package/package.json +5 -3
  52. package/.agents/skills/scrapling-web/SKILL.md +0 -98
  53. package/.pi/extensions/00-posthog-network-bootstrap.ts +0 -11
  54. package/.pi/scripts/harness_web/__pycache__/__init__.cpython-314.pyc +0 -0
  55. package/.pi/scripts/harness_web/__pycache__/config.cpython-314.pyc +0 -0
  56. package/.pi/scripts/harness_web/__pycache__/output.cpython-314.pyc +0 -0
  57. package/.pi/scripts/harness_web/__pycache__/scrape.cpython-314.pyc +0 -0
  58. package/.pi/scripts/harness_web/__pycache__/search.cpython-314.pyc +0 -0
  59. package/.pi/scripts/harness_web/__pycache__/search_ddg.cpython-314.pyc +0 -0
  60. package/.pi/scripts/harness_web/__pycache__/search_searxng.cpython-314.pyc +0 -0
@@ -0,0 +1,182 @@
1
+ # WRS emergency heuristic angles (--expand-heuristic / expandHeuristic:true).
2
+ # Package defaults (ultimate-pi). External projects: copy
3
+ # .pi/harness/examples/web-heuristic-angles.project.yaml to
4
+ # <your-project>/.pi/harness/web-heuristic-angles.yaml and edit.
5
+ #
6
+ # Placeholders: {query} → user search string
7
+ # Order matters: base angles run first, then category (deduped by id, capped at max_angles).
8
+ # JSON mirror (no PyYAML): run `node .pi/scripts/gen-web-heuristic-angles-json.mjs` after edits.
9
+ # Stdlib fallback: .pi/scripts/harness_web/heuristic_angles_shipped.py
10
+
11
+ version: 1
12
+ max_angles: 8
13
+
14
+ base:
15
+ - id: definitional
16
+ query: "{query}"
17
+ rationale: Core intent phrasing
18
+ - id: authoritative
19
+ query: "{query} official documentation OR specification OR RFC"
20
+ rationale: Primary specs and vendor docs
21
+
22
+ categories:
23
+ code:
24
+ - id: github
25
+ query: "{query} site:github.com"
26
+ rationale: Source, issues, discussions
27
+ - id: stackoverflow
28
+ query: "{query} site:stackoverflow.com"
29
+ rationale: Debugging and API usage Q&A
30
+ - id: stackexchange
31
+ query: "{query} site:stackexchange.com"
32
+ rationale: Broader SE network (Super User, Server Fault, etc.)
33
+ - id: readthedocs
34
+ query: "{query} site:readthedocs.io"
35
+ rationale: OSS library documentation
36
+ - id: mdn
37
+ query: "{query} site:developer.mozilla.org"
38
+ rationale: Web platform and browser APIs
39
+ - id: package_registries
40
+ query: "{query} site:npmjs.com OR site:pypi.org OR site:pkg.go.dev OR site:crates.io"
41
+ rationale: Package metadata across major ecosystems
42
+ - id: microsoft_learn
43
+ query: "{query} site:learn.microsoft.com"
44
+ rationale: .NET, Azure, Windows, and enterprise stacks
45
+ - id: hacker_news
46
+ query: "{query} site:news.ycombinator.com"
47
+ rationale: High-signal practitioner discussion
48
+ - id: gitlab
49
+ query: "{query} site:gitlab.com"
50
+ rationale: Alternate host and CI-visible code
51
+ - id: devto
52
+ query: "{query} site:dev.to OR site:medium.com"
53
+ rationale: Tutorials and implementation writeups
54
+
55
+ paper:
56
+ - id: arxiv
57
+ query: "{query} site:arxiv.org"
58
+ rationale: Preprints and latest ML/CS uploads
59
+ - id: semantic_scholar
60
+ query: "{query} site:semanticscholar.org"
61
+ rationale: Citations, influences, and PDF links
62
+ - id: google_scholar
63
+ query: "{query} site:scholar.google.com"
64
+ rationale: Broad academic discovery
65
+ - id: papers_with_code
66
+ query: "{query} site:paperswithcode.com"
67
+ rationale: Benchmarks tied to implementations
68
+ - id: openreview
69
+ query: "{query} site:openreview.net"
70
+ rationale: Peer reviews and ML conference submissions
71
+ - id: acl_anthology
72
+ query: "{query} site:aclanthology.org"
73
+ rationale: NLP and computational linguistics
74
+ - id: acm_dl
75
+ query: "{query} site:dl.acm.org"
76
+ rationale: ACM proceedings and journals
77
+ - id: pubmed
78
+ query: "{query} site:pubmed.ncbi.nlm.nih.gov"
79
+ rationale: Biomedical and life-sciences literature
80
+
81
+ news:
82
+ - id: recent
83
+ query: "{query} news 2025 2026"
84
+ rationale: Recency-biased open web
85
+ - id: wire_reuters
86
+ query: "{query} site:reuters.com"
87
+ rationale: Wire-service reporting
88
+ - id: wire_ap
89
+ query: "{query} site:apnews.com"
90
+ rationale: Associated Press coverage
91
+ - id: tech_press
92
+ query: "{query} site:techcrunch.com OR site:theverge.com OR site:arstechnica.com"
93
+ rationale: Technology industry news
94
+ - id: business_press
95
+ query: "{query} site:bloomberg.com OR site:ft.com OR site:wsj.com"
96
+ rationale: Markets and business context
97
+ - id: analysis
98
+ query: "{query} in-depth analysis explainer"
99
+ rationale: Long-form journalism and explainers
100
+ - id: bbc
101
+ query: "{query} site:bbc.com/news"
102
+ rationale: International general news desk
103
+
104
+ company:
105
+ - id: official_site
106
+ query: "{query} official website"
107
+ rationale: Company-controlled messaging
108
+ - id: crunchbase
109
+ query: "{query} site:crunchbase.com"
110
+ rationale: Funding, investors, and competitors
111
+ - id: linkedin_company
112
+ query: "{query} site:linkedin.com/company"
113
+ rationale: Headcount, hiring, and positioning
114
+ - id: sec_filings
115
+ query: "{query} site:sec.gov 10-K OR 10-Q OR S-1"
116
+ rationale: US public-company disclosures
117
+ - id: g2_reviews
118
+ query: "{query} site:g2.com OR site:capterra.com"
119
+ rationale: B2B software reviews and comparisons
120
+ - id: company_news
121
+ query: "{query} company announcement press release"
122
+ rationale: Launches, partnerships, and earnings
123
+ - id: glassdoor
124
+ query: "{query} site:glassdoor.com"
125
+ rationale: Employee sentiment and culture signals
126
+
127
+ people:
128
+ - id: linkedin
129
+ query: "{query} site:linkedin.com/in"
130
+ rationale: Professional profiles
131
+ - id: github_person
132
+ query: "{query} site:github.com"
133
+ rationale: Open-source footprint for builders
134
+ - id: wikipedia
135
+ query: "{query} site:en.wikipedia.org"
136
+ rationale: Neutral biographical baseline
137
+ - id: scholar_person
138
+ query: "{query} site:scholar.google.com"
139
+ rationale: Publication record for researchers
140
+ - id: interviews
141
+ query: "{query} interview podcast keynote"
142
+ rationale: First-person statements and talks
143
+ - id: twitter_x
144
+ query: "{query} site:x.com OR site:twitter.com"
145
+ rationale: Public statements and discourse
146
+
147
+ security:
148
+ - id: cve_nvd
149
+ query: "{query} CVE site:nvd.nist.gov"
150
+ rationale: National Vulnerability Database
151
+ - id: owasp
152
+ query: "{query} site:owasp.org"
153
+ rationale: AppSec standards and cheat sheets
154
+ - id: cwe
155
+ query: "{query} site:cwe.mitre.org"
156
+ rationale: Weakness taxonomy
157
+ - id: github_advisories
158
+ query: "{query} site:github.com/advisories OR dependabot"
159
+ rationale: Ecosystem security advisories
160
+ - id: snyk_blog
161
+ query: "{query} site:snyk.io/blog OR vulnerability"
162
+ rationale: Practitioner security writeups
163
+
164
+ default:
165
+ - id: technical
166
+ query: "{query} how it works architecture internals"
167
+ rationale: Mechanism and design
168
+ - id: criticism
169
+ query: "{query} limitations criticism drawbacks"
170
+ rationale: Counterpoints and failure modes
171
+ - id: wikipedia
172
+ query: "{query} site:en.wikipedia.org"
173
+ rationale: Structured overview
174
+ - id: comparison
175
+ query: "{query} vs alternatives comparison benchmark"
176
+ rationale: Competitive landscape
177
+ - id: reddit
178
+ query: "{query} site:reddit.com"
179
+ rationale: Community experience reports
180
+ - id: hn_default
181
+ query: "{query} site:news.ycombinator.com"
182
+ rationale: Practitioner threads when category unknown
@@ -67,6 +67,8 @@ function normalizeKindEntry(raw) {
67
67
  typeof raw.thinking === "string" && raw.thinking.trim()
68
68
  ? raw.thinking.trim()
69
69
  : undefined,
70
+ model:
71
+ typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : undefined,
70
72
  };
71
73
  }
72
74
 
@@ -99,6 +101,8 @@ function normalizeAgentEntry(raw) {
99
101
  typeof raw.thinking === "string" && raw.thinking.trim()
100
102
  ? raw.thinking.trim()
101
103
  : undefined,
104
+ model:
105
+ typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : undefined,
102
106
  };
103
107
  }
104
108
 
@@ -165,6 +169,7 @@ export function resolveEffectiveTools(agentId, merged) {
165
169
  readOnly: kind.readOnly,
166
170
  maxTurns: entry.maxTurns ?? kind.maxTurns,
167
171
  thinking: entry.thinking ?? kind.thinking,
172
+ model: entry.model ?? kind.model,
168
173
  submitTool: entry.submitTool,
169
174
  };
170
175
  }
@@ -301,6 +306,7 @@ export function applyAgentPolicyToConfig(agent, packageRoot, projectRoot) {
301
306
  extensionsOff: spec.extensionsOff,
302
307
  maxTurns: spec.maxTurns ?? agent.maxTurns,
303
308
  thinking: spec.thinking ?? agent.thinking,
309
+ model: spec.model ?? agent.model,
304
310
  };
305
311
  }
306
312
 
@@ -2,7 +2,7 @@
2
2
  * Resolve concrete LLM credentials for harness subagent subprocesses.
3
3
  *
4
4
  * Harness subprocesses run with `--no-extensions`, so auth forwarding only uses
5
- * concrete provider/model references from the parent session or agent config.
5
+ * concrete provider/model references from env, agent config, or parent session.
6
6
  */
7
7
 
8
8
  import type { AgentConfig } from "../../vendor/pi-subagents/src/agents.js";
@@ -30,22 +30,52 @@ export interface ConcreteSubagentModel {
30
30
  modelId: string;
31
31
  }
32
32
 
33
+ function toConcrete(ref: string): ConcreteSubagentModel | undefined {
34
+ const parsed = parseModelRef(ref);
35
+ if (!parsed) return undefined;
36
+ return { modelRef: ref, ...parsed };
37
+ }
38
+
39
+ const WEB_FAST_AGENT_IDS = new Set([
40
+ "harness/web-retrieval/web-query-expander-fast",
41
+ "harness/web-retrieval/web-summarizer",
42
+ "harness/web-retrieval/web-gap-analyzer",
43
+ ]);
44
+
45
+ const WEB_QUALITY_AGENT_IDS = new Set([
46
+ "harness/web-retrieval/web-answerer",
47
+ "harness/web-retrieval/web-criteria-verifier",
48
+ ]);
49
+
50
+ function envModelRef(varName: string): string | undefined {
51
+ const v = process.env[varName]?.trim();
52
+ return v && parseModelRef(v) ? v : undefined;
53
+ }
54
+
55
+ function modelFromEnv(agentName: string): ConcreteSubagentModel | undefined {
56
+ const fast = envModelRef("HARNESS_WEB_FAST_MODEL");
57
+ if (fast && WEB_FAST_AGENT_IDS.has(agentName)) return toConcrete(fast);
58
+ const expander = envModelRef("HARNESS_WEB_EXPANDER_MODEL");
59
+ if (expander && agentName === "harness/web-retrieval/web-query-expander") return toConcrete(expander);
60
+ const quality = envModelRef("HARNESS_WEB_QUALITY_MODEL");
61
+ if (quality && WEB_QUALITY_AGENT_IDS.has(agentName)) return toConcrete(quality);
62
+ return undefined;
63
+ }
64
+
33
65
  export function resolveConcreteSubagentModel(
34
66
  _parentCwd: string,
35
67
  parentModel: { provider: string; id: string } | undefined,
36
68
  agent: AgentConfig,
37
69
  _taskSnippet?: string,
38
70
  ): ConcreteSubagentModel | undefined {
71
+ const envOverride = modelFromEnv(agent.name);
72
+ if (envOverride) return envOverride;
73
+
39
74
  if (agent.model) {
40
- const parsed = parseModelRef(agent.model);
41
- if (parsed) {
42
- return { modelRef: agent.model, ...parsed };
43
- }
75
+ const concrete = toConcrete(agent.model);
76
+ if (concrete) return concrete;
44
77
  }
45
78
 
46
79
  if (!parentModel || parentModel.provider === "router") return undefined;
47
- const modelRef = `${parentModel.provider}/${parentModel.id}`;
48
- const parsed = parseModelRef(modelRef);
49
- if (!parsed) return undefined;
50
- return { modelRef, ...parsed };
80
+ return toConcrete(`${parentModel.provider}/${parentModel.id}`);
51
81
  }
@@ -35,6 +35,10 @@ import {
35
35
  recordSpawnStart,
36
36
  } from "./harness-spawn-budget.js";
37
37
  import { parseSpawnContextFromTask } from "./harness-spawn-parse.js";
38
+ import {
39
+ getRememberedSessionWebArtifactDir,
40
+ resolveWebArtifactScope,
41
+ } from "./harness-web/artifacts.js";
38
42
  import {
39
43
  isUsableApiKey,
40
44
  resolveConcreteSubagentModel,
@@ -130,6 +134,23 @@ export function createHarnessSubagentsExtension(
130
134
  HARNESS_PKG_ROOT: packageRoot,
131
135
  HARNESS_PROJECT_ROOT: projectRoot,
132
136
  };
137
+ if (agent.name.startsWith("harness/web-retrieval/")) {
138
+ const ctx = parseSpawnContextFromTask(task);
139
+ const remembered = getRememberedSessionWebArtifactDir(lastSessionId);
140
+ if (remembered) {
141
+ base.HARNESS_WEB_ARTIFACT_DIR = remembered;
142
+ } else if (ctx?.run_id) {
143
+ base.HARNESS_WEB_ARTIFACT_DIR = resolveWebArtifactScope({
144
+ projectRoot,
145
+ explicitArtifactDir: `.web/runs/${ctx.run_id}`,
146
+ }).artifactDir;
147
+ } else {
148
+ base.HARNESS_WEB_ARTIFACT_DIR = resolveWebArtifactScope({
149
+ projectRoot,
150
+ piSessionId: lastSessionId,
151
+ }).artifactDir;
152
+ }
153
+ }
133
154
  const ctx = parseSpawnContextFromTask(task);
134
155
  if (!ctx?.run_id) return base;
135
156
  if (spawnCircuitOpen(ctx.run_id)) {
@@ -0,0 +1,200 @@
1
+ /**
2
+ * WRS workspace paths — flat `.web/` aliases + optional per-run/session isolation.
3
+ * Search/fetch payloads are pooled under `.web/cache/` (see cache.ts).
4
+ */
5
+
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { activeRunPointerPath } from "../harness-run-context.js";
9
+ import { WEB_ROOT, webCacheHint } from "./cache.js";
10
+
11
+ export type WebArtifactScopeSource =
12
+ | "explicit"
13
+ | "run"
14
+ | "session"
15
+ | "workspace";
16
+
17
+ export interface WebArtifactScope {
18
+ /** Relative path under repo root, e.g. `.web` or `.web/runs/abc` */
19
+ artifactDir: string;
20
+ scopeId: string;
21
+ source: WebArtifactScopeSource;
22
+ }
23
+
24
+ function webIsolateEnabled(): boolean {
25
+ return (
26
+ process.env.HARNESS_WEB_ISOLATE === "1" ||
27
+ process.env.HARNESS_WEB_LEGACY_SCOPE === "1"
28
+ );
29
+ }
30
+
31
+ /** Parent session → last resolved artifact dir (for web-retrieval subagent env). */
32
+ const sessionArtifactDirs = new Map<string, string>();
33
+
34
+ const CANONICAL_BASENAMES = new Set([
35
+ "angles.yaml",
36
+ "angles-inline.yaml",
37
+ "search-deep.json",
38
+ "search.json",
39
+ "evidence-bundle.json",
40
+ "answer.md",
41
+ "highlights.json",
42
+ "page.md",
43
+ "map.json",
44
+ ]);
45
+
46
+ export function sanitizeWebScopeId(id: string): string {
47
+ return id.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120);
48
+ }
49
+
50
+ export function isScopedWebArtifactPath(path: string): boolean {
51
+ const n = path.replace(/\\/g, "/");
52
+ if (!n.startsWith(`${WEB_ROOT}/`)) return false;
53
+ const rest = n.slice(`${WEB_ROOT}/`.length);
54
+ const top = rest.split("/")[0];
55
+ return top === "runs" || top === "sessions";
56
+ }
57
+
58
+ function readActiveHarnessRunId(projectRoot: string): string | null {
59
+ const pointerPath = activeRunPointerPath(projectRoot);
60
+ if (!existsSync(pointerPath)) return null;
61
+ try {
62
+ const raw = readFileSync(pointerPath, "utf-8");
63
+ const data = JSON.parse(raw) as { run_id?: string };
64
+ const runId = data.run_id?.trim();
65
+ return runId || null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ export function resolveWebArtifactScope(options: {
72
+ projectRoot: string;
73
+ piSessionId?: string;
74
+ explicitScope?: string;
75
+ explicitArtifactDir?: string;
76
+ }): WebArtifactScope {
77
+ const explicitDir =
78
+ options.explicitArtifactDir?.trim() ||
79
+ options.explicitScope?.trim() ||
80
+ process.env.HARNESS_WEB_ARTIFACT_DIR?.trim() ||
81
+ process.env.HARNESS_WEB_SCOPE?.trim();
82
+ if (explicitDir) {
83
+ const normalized = normalizeArtifactDir(explicitDir);
84
+ return {
85
+ artifactDir: normalized,
86
+ scopeId: normalized.split("/").pop() ?? normalized,
87
+ source: "explicit",
88
+ };
89
+ }
90
+
91
+ if (webIsolateEnabled()) {
92
+ const runId =
93
+ process.env.HARNESS_RUN_ID?.trim() ||
94
+ readActiveHarnessRunId(options.projectRoot);
95
+ if (runId) {
96
+ const id = sanitizeWebScopeId(runId);
97
+ return {
98
+ artifactDir: `${WEB_ROOT}/runs/${id}`,
99
+ scopeId: id,
100
+ source: "run",
101
+ };
102
+ }
103
+
104
+ const sessionId = options.piSessionId?.trim();
105
+ if (sessionId) {
106
+ const id = sanitizeWebScopeId(sessionId);
107
+ return {
108
+ artifactDir: `${WEB_ROOT}/sessions/${id}`,
109
+ scopeId: id,
110
+ source: "session",
111
+ };
112
+ }
113
+ }
114
+
115
+ return {
116
+ artifactDir: WEB_ROOT,
117
+ scopeId: "workspace",
118
+ source: "workspace",
119
+ };
120
+ }
121
+
122
+ export function normalizeArtifactDir(dir: string): string {
123
+ let n = dir.replace(/\\/g, "/").trim();
124
+ if (n.startsWith("./")) n = n.slice(2);
125
+ if (n === WEB_ROOT || n === `${WEB_ROOT}/`) return WEB_ROOT;
126
+ if (!n.startsWith(`${WEB_ROOT}/`)) {
127
+ n = `${WEB_ROOT}/${n.replace(/^\/+/, "")}`;
128
+ }
129
+ return n.replace(/\/+$/, "");
130
+ }
131
+
132
+ export function scopedWebArtifactPath(
133
+ artifactDir: string,
134
+ basename: string,
135
+ ): string {
136
+ const base = normalizeArtifactDir(artifactDir);
137
+ if (base === WEB_ROOT) return `${WEB_ROOT}/${basename}`;
138
+ return `${base}/${basename}`;
139
+ }
140
+
141
+ /**
142
+ * Resolve output path: honor explicit paths; optional isolation rewrites flat canonical names.
143
+ */
144
+ export function resolveWebOutputPath(options: {
145
+ projectRoot: string;
146
+ piSessionId?: string;
147
+ basename: string;
148
+ explicitOutput?: string;
149
+ webScope?: string;
150
+ }): { path: string; artifactDir: string; scope: WebArtifactScope } {
151
+ const scope = resolveWebArtifactScope({
152
+ projectRoot: options.projectRoot,
153
+ piSessionId: options.piSessionId,
154
+ explicitScope: options.webScope,
155
+ });
156
+
157
+ const explicit = options.explicitOutput?.trim();
158
+ if (explicit) {
159
+ const norm = explicit.replace(/\\/g, "/");
160
+ if (isScopedWebArtifactPath(norm)) {
161
+ const artifactDir = norm.slice(0, norm.lastIndexOf("/"));
162
+ return { path: norm, artifactDir, scope };
163
+ }
164
+ const base = norm.split("/").pop() ?? norm;
165
+ if (
166
+ webIsolateEnabled() &&
167
+ scope.source !== "workspace" &&
168
+ norm.startsWith(`${WEB_ROOT}/`) &&
169
+ CANONICAL_BASENAMES.has(base)
170
+ ) {
171
+ const path = scopedWebArtifactPath(scope.artifactDir, base);
172
+ return { path, artifactDir: scope.artifactDir, scope };
173
+ }
174
+ return { path: norm, artifactDir: scope.artifactDir, scope };
175
+ }
176
+
177
+ const path = scopedWebArtifactPath(scope.artifactDir, options.basename);
178
+ return { path, artifactDir: scope.artifactDir, scope };
179
+ }
180
+
181
+ export function rememberSessionWebArtifactDir(
182
+ sessionId: string,
183
+ artifactDir: string,
184
+ ): void {
185
+ if (!sessionId?.trim() || !artifactDir?.trim()) return;
186
+ sessionArtifactDirs.set(sessionId.trim(), normalizeArtifactDir(artifactDir));
187
+ }
188
+
189
+ export function getRememberedSessionWebArtifactDir(
190
+ sessionId: string,
191
+ ): string | undefined {
192
+ return sessionArtifactDirs.get(sessionId.trim());
193
+ }
194
+
195
+ export function webArtifactScopeHint(scope: WebArtifactScope): string {
196
+ const isolateNote = webIsolateEnabled()
197
+ ? `Isolation on (${scope.artifactDir}/). Set HARNESS_WEB_ISOLATE=0 for shared workspace only.`
198
+ : `Shared workspace ${scope.artifactDir}/ for angles, search-deep, answer.md. Set HARNESS_WEB_ISOLATE=1 to isolate per session/run.`;
199
+ return `[WRS workspace] ${isolateNote} ${webCacheHint()}`;
200
+ }