ultimate-pi 0.19.0 → 0.20.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 (85) 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/running/executor.md +42 -1
  7. package/.pi/agents/harness/web-retrieval/web-answerer.md +35 -0
  8. package/.pi/agents/harness/web-retrieval/web-criteria-verifier.md +28 -0
  9. package/.pi/agents/harness/web-retrieval/web-gap-analyzer.md +31 -0
  10. package/.pi/agents/harness/web-retrieval/web-query-expander-fast.md +34 -0
  11. package/.pi/agents/harness/web-retrieval/web-query-expander.md +60 -0
  12. package/.pi/agents/harness/web-retrieval/web-summarizer.md +18 -0
  13. package/.pi/extensions/harness-anchored-edit.ts +141 -0
  14. package/.pi/extensions/harness-web-guard.ts +2 -1
  15. package/.pi/extensions/harness-web-tools.ts +689 -51
  16. package/.pi/harness/agents.manifest.json +30 -6
  17. package/.pi/harness/agents.policy.yaml +37 -4
  18. package/.pi/harness/docs/adrs/0050-agentic-web-retrieval-stack.md +46 -0
  19. package/.pi/harness/docs/adrs/0051-hash-anchored-executor-edits.md +41 -0
  20. package/.pi/harness/docs/adrs/README.md +2 -0
  21. package/.pi/harness/docs/harness-web-search.md +97 -0
  22. package/.pi/harness/docs/practice-map.md +11 -0
  23. package/.pi/harness/env.harness.template +9 -1
  24. package/.pi/harness/examples/web-heuristic-angles.project.yaml +22 -0
  25. package/.pi/harness/web-heuristic-angles.json +278 -0
  26. package/.pi/harness/web-heuristic-angles.yaml +182 -0
  27. package/.pi/lib/agents-policy.d.mts +4 -0
  28. package/.pi/lib/agents-policy.mjs +49 -1
  29. package/.pi/lib/agents-policy.ts +1 -0
  30. package/.pi/lib/harness-anchored-edit/.hash_anchors +1721 -0
  31. package/.pi/lib/harness-anchored-edit/anchor-state.ts +320 -0
  32. package/.pi/lib/harness-anchored-edit/apply-anchored-edits.ts +161 -0
  33. package/.pi/lib/harness-anchored-edit/edit-executor.ts +146 -0
  34. package/.pi/lib/harness-anchored-edit/index.ts +9 -0
  35. package/.pi/lib/harness-anchored-edit/line-protocol.ts +38 -0
  36. package/.pi/lib/harness-anchored-edit/settings.ts +1 -0
  37. package/.pi/lib/harness-anchored-edit/task-id.ts +8 -0
  38. package/.pi/lib/harness-anchored-edit/types.ts +19 -0
  39. package/.pi/lib/harness-lens/clients/anchored-edit-autopatch.ts +158 -0
  40. package/.pi/lib/harness-lens/index.ts +24 -7
  41. package/.pi/lib/harness-subagent-auth.ts +39 -9
  42. package/.pi/lib/harness-subagents-bridge.ts +24 -1
  43. package/.pi/lib/harness-web/artifacts.ts +200 -0
  44. package/.pi/lib/harness-web/cache.ts +369 -0
  45. package/.pi/lib/harness-web/run-cli.ts +42 -2
  46. package/.pi/prompts/harness-plan.md +1 -0
  47. package/.pi/prompts/harness-setup.md +3 -1
  48. package/.pi/prompts/harness-steer.md +1 -1
  49. package/.pi/scripts/gen-web-heuristic-angles-json.mjs +24 -0
  50. package/.pi/scripts/harness-anchored-edit-smoke.mjs +45 -0
  51. package/.pi/scripts/harness-cli-verify.sh +5 -0
  52. package/.pi/scripts/harness-verify.mjs +145 -0
  53. package/.pi/scripts/harness-web-policy-guard.mjs +1 -1
  54. package/.pi/scripts/harness-web.py +218 -15
  55. package/.pi/scripts/harness_web/deep_search.py +55 -0
  56. package/.pi/scripts/harness_web/evidence_bundle.py +47 -0
  57. package/.pi/scripts/harness_web/find_similar.py +88 -0
  58. package/.pi/scripts/harness_web/heuristic_angles_shipped.py +85 -0
  59. package/.pi/scripts/harness_web/heuristic_config.py +251 -0
  60. package/.pi/scripts/harness_web/highlights.py +47 -0
  61. package/.pi/scripts/harness_web/multi_search.py +59 -0
  62. package/.pi/scripts/harness_web/output.py +24 -0
  63. package/.pi/scripts/harness_web/query_angles.py +116 -0
  64. package/.pi/scripts/harness_web/rank.py +163 -0
  65. package/.pi/scripts/harness_web/scrape.py +30 -0
  66. package/.pi/scripts/run-tests.mjs +64 -0
  67. package/.pi/scripts/tests/test_harness_web_heuristic_config.py +132 -0
  68. package/.pi/scripts/tests/test_harness_web_query_angles.py +45 -0
  69. package/.pi/scripts/tests/test_harness_web_rank.py +56 -0
  70. package/AGENTS.md +2 -2
  71. package/CHANGELOG.md +12 -0
  72. package/THIRD_PARTY_NOTICES.md +7 -0
  73. package/package.json +7 -4
  74. package/vendor/pi-subagents/src/agents.ts +5 -0
  75. package/vendor/pi-subagents/src/subagents.ts +22 -3
  76. package/.agents/skills/scrapling-web/SKILL.md +0 -98
  77. package/.pi/extensions/00-posthog-network-bootstrap.ts +0 -11
  78. package/.pi/scripts/harness_web/__pycache__/__init__.cpython-314.pyc +0 -0
  79. package/.pi/scripts/harness_web/__pycache__/config.cpython-314.pyc +0 -0
  80. package/.pi/scripts/harness_web/__pycache__/output.cpython-314.pyc +0 -0
  81. package/.pi/scripts/harness_web/__pycache__/scrape.cpython-314.pyc +0 -0
  82. package/.pi/scripts/harness_web/__pycache__/search.cpython-314.pyc +0 -0
  83. package/.pi/scripts/harness_web/__pycache__/search_ddg.cpython-314.pyc +0 -0
  84. package/.pi/scripts/harness_web/__pycache__/search_searxng.cpython-314.pyc +0 -0
  85. package/.pi/scripts/release.sh +0 -338
@@ -0,0 +1,158 @@
1
+ import * as nodeFs from "node:fs";
2
+ import { AnchorStateManager } from "../../harness-anchored-edit/anchor-state.js";
3
+ import { EditExecutor } from "../../harness-anchored-edit/edit-executor.js";
4
+ import { splitAnchor } from "../../harness-anchored-edit/line-protocol.js";
5
+ import type { AnchoredEdit } from "../../harness-anchored-edit/types.js";
6
+ import { tryCorrectIndentationMismatchFromContent } from "./edit-autopatch.js";
7
+ import { retargetReplacementIndentation } from "./indent-retarget.js";
8
+
9
+ function leadingIndent(line: string): string {
10
+ return line.match(/^[\t ]*/)?.[0] ?? "";
11
+ }
12
+
13
+ function isIndentationOnlyChange(before: string, after: string): boolean {
14
+ const beforeLines = before.replace(/\r\n/g, "\n").split("\n");
15
+ const afterLines = after.replace(/\r\n/g, "\n").split("\n");
16
+ if (beforeLines.length !== afterLines.length) return false;
17
+ return beforeLines.every(
18
+ (line, index) => line.trim() === afterLines[index].trim(),
19
+ );
20
+ }
21
+
22
+ type AnchoredEditInput = {
23
+ edits?: AnchoredEdit[];
24
+ };
25
+
26
+ export function isAnchoredEditToolInput(
27
+ editInput: unknown,
28
+ ): editInput is AnchoredEditInput {
29
+ if (!editInput || typeof editInput !== "object") return false;
30
+ const edits = (editInput as AnchoredEditInput).edits;
31
+ if (!Array.isArray(edits) || edits.length === 0) return false;
32
+ return typeof edits[0]?.anchor === "string";
33
+ }
34
+
35
+ /**
36
+ * Indentation-only correction for harness anchored edit.text before apply.
37
+ */
38
+ export function applyAnchoredEditAutopatch(
39
+ filePath: string,
40
+ editInput: AnchoredEditInput,
41
+ taskId: string,
42
+ ): { block: true; reason: string } | undefined {
43
+ const edits = editInput.edits;
44
+ if (!edits?.length) return undefined;
45
+
46
+ let crlfContent: string;
47
+ try {
48
+ crlfContent = nodeFs.readFileSync(filePath, "utf-8").replace(/\r\n/g, "\n");
49
+ } catch {
50
+ return undefined;
51
+ }
52
+
53
+ const lines = crlfContent.split("\n");
54
+ const lineAnchors = AnchorStateManager.reconcile(filePath, lines, taskId);
55
+ const executor = new EditExecutor();
56
+ const { resolvedEdits, failedEdits } = executor.resolveEdits(
57
+ edits,
58
+ lines,
59
+ lineAnchors,
60
+ );
61
+ if (failedEdits.length > 0) return undefined;
62
+
63
+ const corrected: Array<{
64
+ label: string;
65
+ original: string;
66
+ corrected: string;
67
+ indentationOnly: boolean;
68
+ apply: (value: string) => void;
69
+ }> = [];
70
+
71
+ for (const { lineIdx, endIdx, edit } of resolvedEdits) {
72
+ const editType = edit.edit_type ?? "replace";
73
+ const text = edit.text ?? "";
74
+ if (!text.trim()) continue;
75
+
76
+ let referenceBlock: string;
77
+ if (editType === "replace") {
78
+ referenceBlock = lines.slice(lineIdx, endIdx + 1).join("\n");
79
+ } else {
80
+ referenceBlock = lines[lineIdx] ?? "";
81
+ }
82
+
83
+ const correctedText = tryCorrectIndentationMismatchFromContent(
84
+ text,
85
+ crlfContent,
86
+ );
87
+ if (correctedText === undefined) {
88
+ const refIndent = leadingIndent(referenceBlock.split("\n")[0] ?? "");
89
+ const textIndent = leadingIndent(text.split("\n")[0] ?? "");
90
+ if (
91
+ refIndent !== textIndent &&
92
+ isIndentationOnlyChange(
93
+ textIndent + text.trimStart(),
94
+ refIndent + text.trimStart(),
95
+ )
96
+ ) {
97
+ const retargeted = retargetReplacementIndentation(
98
+ text,
99
+ textIndent + text.trimStart(),
100
+ refIndent + text.trimStart(),
101
+ );
102
+ if (retargeted !== undefined) {
103
+ corrected.push({
104
+ label: `edits anchor ${splitAnchor(edit.anchor).anchor}`,
105
+ original: text,
106
+ corrected: retargeted,
107
+ indentationOnly: true,
108
+ apply: (value) => {
109
+ edit.text = value;
110
+ },
111
+ });
112
+ }
113
+ }
114
+ continue;
115
+ }
116
+
117
+ if (correctedText !== text) {
118
+ const retargeted = retargetReplacementIndentation(
119
+ text,
120
+ text,
121
+ correctedText,
122
+ );
123
+ corrected.push({
124
+ label: `edits anchor ${splitAnchor(edit.anchor).anchor}`,
125
+ original: text,
126
+ corrected: retargeted ?? correctedText,
127
+ indentationOnly: isIndentationOnlyChange(text, correctedText),
128
+ apply: (value) => {
129
+ edit.text = value;
130
+ },
131
+ });
132
+ }
133
+ }
134
+
135
+ if (corrected.length === 0) return undefined;
136
+
137
+ const unsafe = corrected.filter((entry) => !entry.indentationOnly);
138
+ if (unsafe.length > 0) {
139
+ const details = unsafe
140
+ .map(({ label, original }) => {
141
+ const preview = original.trimStart().slice(0, 60).replace(/\n/g, "↵");
142
+ return `${label} ("${preview}…") cannot be auto-patched (not indentation-only).`;
143
+ })
144
+ .join("\n");
145
+ return {
146
+ block: true,
147
+ reason:
148
+ `🔄 RETRYABLE — Indentation mismatch on anchored edit text\n\n` +
149
+ `${details}\n\n` +
150
+ `Next action: re-read the relevant section, then retry with text matching file indentation.`,
151
+ };
152
+ }
153
+
154
+ for (const entry of corrected) {
155
+ entry.apply(entry.corrected);
156
+ }
157
+ return undefined;
158
+ }
@@ -7,6 +7,11 @@ import * as nodeFs from "node:fs";
7
7
  import * as path from "node:path";
8
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
9
  import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
10
+ import { anchoredEditTaskId } from "../harness-anchored-edit/task-id.js";
11
+ import {
12
+ applyAnchoredEditAutopatch,
13
+ isAnchoredEditToolInput,
14
+ } from "./clients/anchored-edit-autopatch.js";
10
15
  import {
11
16
  tryCorrectIndentationMismatch,
12
17
  tryCorrectIndentationMismatchFromContent,
@@ -496,13 +501,25 @@ export default function harnessLensExtension(pi: ExtensionAPI): void {
496
501
  event as Parameters<typeof isToolCallEventType>[1],
497
502
  )
498
503
  ) {
499
- const editInput = (event as { input?: unknown }).input as {
500
- oldText?: string;
501
- newText?: string;
502
- edits?: Array<{ oldText?: string; newText?: string }>;
503
- };
504
- const block = applyEditAutopatch(filePath, editInput);
505
- if (block) return block;
504
+ const editInput = (event as { input?: unknown }).input;
505
+ if (isAnchoredEditToolInput(editInput)) {
506
+ const block = applyAnchoredEditAutopatch(
507
+ filePath,
508
+ editInput,
509
+ anchoredEditTaskId({
510
+ sessionId: (ctx as { sessionId?: string }).sessionId,
511
+ }),
512
+ );
513
+ if (block) return block;
514
+ } else {
515
+ const legacyInput = editInput as {
516
+ oldText?: string;
517
+ newText?: string;
518
+ edits?: Array<{ oldText?: string; newText?: string }>;
519
+ };
520
+ const block = applyEditAutopatch(filePath, legacyInput);
521
+ if (block) return block;
522
+ }
506
523
  }
507
524
  });
508
525
 
@@ -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
  }
@@ -14,7 +14,7 @@ import {
14
14
  type SpawnAuthForward,
15
15
  } from "../../vendor/pi-subagents/src/subagents.js";
16
16
  import { subagentGovernanceExtensionPath } from "../extensions/subagent-governance.js";
17
- import { getAgentKind } from "./agents-policy.mjs";
17
+ import { getAgentKind, resolveExtensionBundlePaths } from "./agents-policy.mjs";
18
18
  import {
19
19
  delegationEnvFromBundle,
20
20
  mintSubagentDelegation,
@@ -43,6 +43,10 @@ import {
43
43
  inferPhaseForPrecheck,
44
44
  precheckHarnessSubagentSpawn,
45
45
  } from "./harness-subagent-precheck.js";
46
+ import {
47
+ getRememberedSessionWebArtifactDir,
48
+ resolveWebArtifactScope,
49
+ } from "./harness-web/artifacts.js";
46
50
 
47
51
  const spawnBudget = createSpawnBudgetState();
48
52
  let lastSessionId = "harness";
@@ -122,6 +126,8 @@ export function createHarnessSubagentsExtension(
122
126
  packageRoot,
123
127
  subprocessGovernanceExtensionPath: governanceExtPath,
124
128
  harnessSubprocessExtensionPath: governanceExtPath,
129
+ resolveExtensionBundlePaths: (bundleName) =>
130
+ resolveExtensionBundlePaths(packageRoot, bundleName),
125
131
  resolveSubprocessEnv: (task, agent) => {
126
132
  const projectRoot = process.cwd();
127
133
  const base: Record<string, string> = {
@@ -130,6 +136,23 @@ export function createHarnessSubagentsExtension(
130
136
  HARNESS_PKG_ROOT: packageRoot,
131
137
  HARNESS_PROJECT_ROOT: projectRoot,
132
138
  };
139
+ if (agent.name.startsWith("harness/web-retrieval/")) {
140
+ const ctx = parseSpawnContextFromTask(task);
141
+ const remembered = getRememberedSessionWebArtifactDir(lastSessionId);
142
+ if (remembered) {
143
+ base.HARNESS_WEB_ARTIFACT_DIR = remembered;
144
+ } else if (ctx?.run_id) {
145
+ base.HARNESS_WEB_ARTIFACT_DIR = resolveWebArtifactScope({
146
+ projectRoot,
147
+ explicitArtifactDir: `.web/runs/${ctx.run_id}`,
148
+ }).artifactDir;
149
+ } else {
150
+ base.HARNESS_WEB_ARTIFACT_DIR = resolveWebArtifactScope({
151
+ projectRoot,
152
+ piSessionId: lastSessionId,
153
+ }).artifactDir;
154
+ }
155
+ }
133
156
  const ctx = parseSpawnContextFromTask(task);
134
157
  if (!ctx?.run_id) return base;
135
158
  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
+ }