@f5xc-salesdemos/xcsh 18.55.0 → 18.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.55.0",
4
+ "version": "18.57.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -48,12 +48,12 @@
48
48
  "dependencies": {
49
49
  "@agentclientprotocol/sdk": "0.16.1",
50
50
  "@mozilla/readability": "^0.6",
51
- "@f5xc-salesdemos/xcsh-stats": "18.55.0",
52
- "@f5xc-salesdemos/pi-agent-core": "18.55.0",
53
- "@f5xc-salesdemos/pi-ai": "18.55.0",
54
- "@f5xc-salesdemos/pi-natives": "18.55.0",
55
- "@f5xc-salesdemos/pi-tui": "18.55.0",
56
- "@f5xc-salesdemos/pi-utils": "18.55.0",
51
+ "@f5xc-salesdemos/xcsh-stats": "18.57.0",
52
+ "@f5xc-salesdemos/pi-agent-core": "18.57.0",
53
+ "@f5xc-salesdemos/pi-ai": "18.57.0",
54
+ "@f5xc-salesdemos/pi-natives": "18.57.0",
55
+ "@f5xc-salesdemos/pi-tui": "18.57.0",
56
+ "@f5xc-salesdemos/pi-utils": "18.57.0",
57
57
  "@sinclair/typebox": "^0.34",
58
58
  "@xterm/headless": "^6.0",
59
59
  "ajv": "^8.18",
@@ -1,18 +1,22 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { inferMetricUnitFromName } from "./helpers";
3
+ import { inferMetricUnitFromName, normalizeAutoresearchPath } from "./helpers";
4
4
  import type { AutoresearchContract, ExperimentState, MetricDirection } from "./types";
5
5
 
6
6
  const HEADING_REGEX = /^##\s+(.+?)\s*$/;
7
7
  const LIST_ITEM_REGEX = /^\s*[-*]\s+(.*)$/;
8
8
  const KEY_VALUE_REGEX = /^\s*[-*]\s+([^:]+):\s*(.*)$/;
9
-
10
- export function readAutoresearchContract(workDir: string) {
11
- const contractPath = path.join(workDir, "autoresearch.md");
12
- let content = "";
9
+ function tryReadFile(filePath: string): string | null {
13
10
  try {
14
- content = fs.readFileSync(contractPath, "utf8");
11
+ return fs.readFileSync(filePath, "utf8");
15
12
  } catch {
13
+ return null;
14
+ }
15
+ }
16
+ export function readAutoresearchContract(workDir: string) {
17
+ const contractPath = path.join(workDir, "autoresearch.md");
18
+ const content = tryReadFile(contractPath);
19
+ if (content === null)
16
20
  return {
17
21
  contract: {
18
22
  benchmark: { command: null, primaryMetric: null, metricUnit: "", direction: null, secondaryMetrics: [] },
@@ -23,13 +27,9 @@ export function readAutoresearchContract(workDir: string) {
23
27
  errors: [`${contractPath} does not exist. Create it before initializing autoresearch.`],
24
28
  path: contractPath,
25
29
  };
26
- }
27
-
28
30
  const contract = parseAutoresearchContract(content);
29
- const errors = validateAutoresearchContract(contract);
30
- return { contract, errors, path: contractPath };
31
+ return { contract, errors: validateAutoresearchContract(contract), path: contractPath };
31
32
  }
32
-
33
33
  export function parseAutoresearchContract(markdown: string): AutoresearchContract {
34
34
  const sections = extractSections(markdown);
35
35
  return {
@@ -39,7 +39,6 @@ export function parseAutoresearchContract(markdown: string): AutoresearchContrac
39
39
  constraints: parseListSection(sections.get("constraints") ?? ""),
40
40
  };
41
41
  }
42
-
43
42
  function validateAutoresearchContract(contract: AutoresearchContract): string[] {
44
43
  const errors: string[] = [];
45
44
  if (!contract.benchmark.command) errors.push("Benchmark.command is required in autoresearch.md.");
@@ -48,77 +47,54 @@ function validateAutoresearchContract(contract: AutoresearchContract): string[]
48
47
  errors.push("Benchmark.direction must be `lower` or `higher` in autoresearch.md.");
49
48
  if (contract.scopePaths.length === 0)
50
49
  errors.push("Files in Scope must contain at least one path in autoresearch.md.");
51
- for (const p of contract.scopePaths) {
52
- if (isUnsafeContractPathSpec(p)) errors.push(`Files in Scope contains an invalid path: ${p}`);
53
- }
54
- for (const p of contract.offLimits) {
55
- if (isUnsafeContractPathSpec(p)) errors.push(`Off Limits contains an invalid path: ${p}`);
56
- }
50
+ for (const [label, paths] of [
51
+ ["Files in Scope", contract.scopePaths],
52
+ ["Off Limits", contract.offLimits],
53
+ ] as const)
54
+ for (const p of paths)
55
+ if (path.posix.isAbsolute(p) || p === ".." || p.startsWith("../"))
56
+ errors.push(`${label} contains an invalid path: ${p}`);
57
57
  return errors;
58
58
  }
59
-
60
59
  export function loadAutoresearchScriptSnapshot(workDir: string) {
61
60
  const benchmarkScriptPath = path.join(workDir, "autoresearch.sh");
62
61
  const checksScriptPath = path.join(workDir, "autoresearch.checks.sh");
63
- const errors: string[] = [];
64
-
65
- let benchmarkScript = "";
66
- try {
67
- benchmarkScript = fs.readFileSync(benchmarkScriptPath, "utf8");
68
- } catch {
69
- errors.push(`${benchmarkScriptPath} does not exist. Create it before initializing autoresearch.`);
70
- }
71
-
72
- let checksScript: string | null = null;
73
- try {
74
- checksScript = fs.readFileSync(checksScriptPath, "utf8");
75
- } catch {
76
- checksScript = null;
77
- }
78
-
62
+ const benchmarkScript = tryReadFile(benchmarkScriptPath);
79
63
  return {
80
- benchmarkScript,
64
+ benchmarkScript: benchmarkScript ?? "",
81
65
  benchmarkScriptPath,
82
- checksScript,
66
+ checksScript: tryReadFile(checksScriptPath),
83
67
  checksScriptPath,
84
- errors,
68
+ errors:
69
+ benchmarkScript === null
70
+ ? [`${benchmarkScriptPath} does not exist. Create it before initializing autoresearch.`]
71
+ : [],
85
72
  };
86
73
  }
87
-
88
74
  export function normalizeAutoresearchList(values: readonly string[]): string[] {
89
75
  return [...new Set(values.map(v => v.trim()).filter(Boolean))];
90
76
  }
91
-
92
77
  export function normalizeContractPathSpec(value: string): string {
93
- const normalized = path.posix.normalize(value.trim().replaceAll("\\", "/"));
94
- if (normalized === "." || normalized === "./") return ".";
95
- return normalized.replace(/^\.\/+/, "").replace(/\/+$/, "");
78
+ return normalizeAutoresearchPath(path.posix.normalize(value.trim().replaceAll("\\", "/")));
96
79
  }
97
-
98
80
  export function pathMatchesContractPath(pathValue: string, specValue: string): boolean {
99
81
  const normalizedPath = normalizeContractPathSpec(pathValue);
100
82
  const normalizedSpec = normalizeContractPathSpec(specValue);
101
83
  if (normalizedSpec === ".") return true;
102
84
  return normalizedPath === normalizedSpec || normalizedPath.startsWith(`${normalizedSpec}/`);
103
85
  }
104
-
105
86
  export function contractListsEqual(left: readonly string[], right: readonly string[]): boolean {
106
- return arraysEqual(normalizeAutoresearchList(left), normalizeAutoresearchList(right));
87
+ const a = normalizeAutoresearchList(left);
88
+ const b = normalizeAutoresearchList(right);
89
+ return a.length === b.length && a.every((v, i) => v === b[i]);
107
90
  }
108
-
109
91
  export function contractPathListsEqual(left: readonly string[], right: readonly string[]): boolean {
110
- return arraysEqual(normalizeContractPathList(left), normalizeContractPathList(right));
111
- }
112
-
113
- function arraysEqual(a: string[], b: string[]): boolean {
92
+ const norm = (values: readonly string[]) =>
93
+ normalizeAutoresearchList(values.map(normalizeContractPathSpec)).sort((l, r) => l.localeCompare(r));
94
+ const a = norm(left);
95
+ const b = norm(right);
114
96
  return a.length === b.length && a.every((v, i) => v === b[i]);
115
97
  }
116
- function normalizeContractPathList(values: readonly string[]): string[] {
117
- return normalizeAutoresearchList(values.map(normalizeContractPathSpec)).sort((left, right) =>
118
- left.localeCompare(right),
119
- );
120
- }
121
-
122
98
  function extractSections(markdown: string): Map<string, string> {
123
99
  const sections = new Map<string, string>();
124
100
  let heading: string | null = null;
@@ -136,7 +112,6 @@ function extractSections(markdown: string): Map<string, string> {
136
112
  if (heading) sections.set(heading, content.join("\n").trim());
137
113
  return sections;
138
114
  }
139
-
140
115
  function parseBenchmarkSection(section: string): AutoresearchContract["benchmark"] {
141
116
  const entries = new Map<string, string>();
142
117
  const lines = section.split("\n");
@@ -181,7 +156,6 @@ function parseBenchmarkSection(section: string): AutoresearchContract["benchmark
181
156
  : [],
182
157
  };
183
158
  }
184
-
185
159
  function parseListSection(section: string, normalizeItem?: (value: string) => string): string[] {
186
160
  const items: string[] = [];
187
161
  let activeItem: string | null = null;
@@ -208,10 +182,6 @@ function parseListSection(section: string, normalizeItem?: (value: string) => st
208
182
  const normalizedItems = normalizeAutoresearchList(items);
209
183
  return normalizeItem ? normalizedItems.map(normalizeItem) : normalizedItems;
210
184
  }
211
- function isUnsafeContractPathSpec(value: string): boolean {
212
- return path.posix.isAbsolute(value) || value === ".." || value.startsWith("../");
213
- }
214
-
215
185
  /**
216
186
  * Updates session fields from a validated `autoresearch.md` parse (same fields as `init_experiment`).
217
187
  * Does not touch `name`, `currentSegment`, `results`, `bestMetric`, `confidence`, or `maxExperiments`.
@@ -1,7 +1,13 @@
1
1
  import { matchesKey, replaceTabs, Text, truncateToWidth, visibleWidth } from "@f5xc-salesdemos/pi-tui";
2
2
  import type { Theme } from "../modes/theme/theme";
3
- import { formatElapsed, formatNum, isBetter } from "./helpers";
4
- import { currentResults, findBaselineMetric, findBaselineRunNumber, findBaselineSecondary } from "./state";
3
+ import { formatElapsed, formatNum } from "./helpers";
4
+ import {
5
+ currentResults,
6
+ findBaselineMetric,
7
+ findBaselineRunNumber,
8
+ findBaselineSecondary,
9
+ findBestKeptResult,
10
+ } from "./state";
5
11
  import type { AutoresearchRuntime, DashboardController, ExperimentResult, ExperimentState } from "./types";
6
12
 
7
13
  export function createDashboardController(): DashboardController {
@@ -15,10 +21,8 @@ export function createDashboardController(): DashboardController {
15
21
 
16
22
  const clear = (): void => {
17
23
  overlayTui = null;
18
- if (spinnerTimer) {
19
- clearInterval(spinnerTimer);
20
- spinnerTimer = undefined;
21
- }
24
+ clearInterval(spinnerTimer);
25
+ spinnerTimer = undefined;
22
26
  };
23
27
 
24
28
  return {
@@ -117,14 +121,12 @@ export function createDashboardController(): DashboardController {
117
121
  },
118
122
  };
119
123
  }
120
-
121
124
  function renderRunningOnly(runtime: AutoresearchRuntime, state: ExperimentState, theme: Theme): string {
122
125
  const parts = [theme.fg("contentAccent", "autoresearch"), theme.fg("warning", " running...")];
123
126
  if (state.name) parts.push(theme.fg("dim", ` | ${replaceTabs(state.name)}`));
124
127
  if (runtime.runningExperiment) parts.push(theme.fg("dim", ` | ${replaceTabs(runtime.runningExperiment.command)}`));
125
128
  return parts.join("");
126
129
  }
127
-
128
130
  function shouldShowDashboard(runtime: AutoresearchRuntime, state: ExperimentState): boolean {
129
131
  return (
130
132
  runtime.autoresearchMode ||
@@ -133,7 +135,6 @@ function shouldShowDashboard(runtime: AutoresearchRuntime, state: ExperimentStat
133
135
  runtime.lastRunSummary !== null
134
136
  );
135
137
  }
136
-
137
138
  function renderExpandedHeader(runtime: AutoresearchRuntime, width: number, theme: Theme): string {
138
139
  const state = runtime.state;
139
140
  const status = renderModeStatus(runtime, state);
@@ -145,7 +146,6 @@ function renderExpandedHeader(runtime: AutoresearchRuntime, width: number, theme
145
146
  width,
146
147
  );
147
148
  }
148
-
149
149
  function renderCollapsedLine(runtime: AutoresearchRuntime, state: ExperimentState, theme: Theme): string {
150
150
  if (runtime.lastRunSummary) {
151
151
  const parts = [
@@ -153,14 +153,13 @@ function renderCollapsedLine(runtime: AutoresearchRuntime, state: ExperimentStat
153
153
  theme.fg("warning", ` pending run #${runtime.lastRunSummary.runNumber}`),
154
154
  theme.fg("dim", runtime.lastRunSummary.passed ? " pass" : " fail"),
155
155
  ];
156
- if (runtime.lastRunSummary.parsedPrimary !== null) {
156
+ if (runtime.lastRunSummary.parsedPrimary !== null)
157
157
  parts.push(
158
158
  theme.fg(
159
159
  "muted",
160
160
  ` | ${state.metricName}=${formatNum(runtime.lastRunSummary.parsedPrimary, state.metricUnit)}`,
161
161
  ),
162
162
  );
163
- }
164
163
  parts.push(theme.fg("warning", " | log_experiment required"));
165
164
  if (!runtime.autoresearchMode) parts.push(theme.fg("dim", " | mode off"));
166
165
  return parts.join("");
@@ -176,7 +175,7 @@ function renderCollapsedLine(runtime: AutoresearchRuntime, state: ExperimentStat
176
175
  const kept = current.filter(result => result.status === "keep").length;
177
176
  const crashed = current.filter(result => result.status === "crash").length;
178
177
  const checksFailed = current.filter(result => result.status === "checks_failed").length;
179
- const best = findBestResult(state);
178
+ const best = findBestKeptResult(state);
180
179
  const archivedRuns = Math.max(0, state.results.length - current.length);
181
180
  const parts = [
182
181
  theme.fg("contentAccent", "autoresearch"),
@@ -197,8 +196,7 @@ function renderCollapsedLine(runtime: AutoresearchRuntime, state: ExperimentStat
197
196
  }
198
197
  if (state.confidence !== null) {
199
198
  const confidenceColor = state.confidence >= 2 ? "success" : state.confidence >= 1 ? "warning" : "error";
200
- parts.push(theme.fg("dim", " | "));
201
- parts.push(theme.fg(confidenceColor, `conf ${state.confidence.toFixed(1)}x`));
199
+ parts.push(theme.fg("dim", " | "), theme.fg(confidenceColor, `conf ${state.confidence.toFixed(1)}x`));
202
200
  }
203
201
  if (runtime.runningExperiment) {
204
202
  parts.push(theme.fg("dim", ` | running ${formatElapsed(Date.now() - runtime.runningExperiment.startedAt)}`));
@@ -208,7 +206,6 @@ function renderCollapsedLine(runtime: AutoresearchRuntime, state: ExperimentStat
208
206
  parts.push(theme.fg("dim", " | ctrl+x expand"));
209
207
  return parts.join("");
210
208
  }
211
-
212
209
  function renderDashboardLines(runtime: AutoresearchRuntime, width: number, theme: Theme, maxRows: number): string[] {
213
210
  const state = runtime.state;
214
211
  if (state.results.length === 0) {
@@ -242,7 +239,7 @@ function renderDashboardLines(runtime: AutoresearchRuntime, width: number, theme
242
239
  const baseline = findBaselineMetric(state.results, state.currentSegment);
243
240
  const baselineRunNumber = findBaselineRunNumber(state.results, state.currentSegment);
244
241
  const baselineSecondary = findBaselineSecondary(state.results, state.currentSegment, state.secondaryMetrics);
245
- const best = findBestResult(state);
242
+ const best = findBestKeptResult(state);
246
243
  const lines = [
247
244
  truncateToWidth(
248
245
  `Current segment: ${current.length} runs ${kept} kept ${discarded} discarded ${crashed} crashed ${checksFailed} checks_failed`,
@@ -268,28 +265,31 @@ function renderDashboardLines(runtime: AutoresearchRuntime, width: number, theme
268
265
  }
269
266
  if (!runtime.autoresearchMode) lines.push(truncateToWidth(`Mode: ${renderModeStatus(runtime, state)}`, width));
270
267
  if (best) {
271
- const bestRunNumber = best.result.runNumber ?? best.index + 1;
272
- let progress = `Best: ${formatNum(best.result.metric, state.metricUnit)} (#${bestRunNumber})`;
268
+ let progress = `Best: ${formatNum(best.result.metric, state.metricUnit)} (#${best.result.runNumber ?? best.index + 1})`;
273
269
  if (baseline !== null && baseline !== 0 && best.result.metric !== baseline)
274
270
  progress += ` ${formatDelta(best.result.metric, baseline)}`;
275
271
  if (state.confidence !== null) progress += ` conf ${state.confidence.toFixed(1)}x`;
276
272
  lines.push(truncateToWidth(progress, width));
277
273
  if (state.secondaryMetrics.length > 0) {
278
274
  const details = state.secondaryMetrics
279
- .map(metric =>
280
- renderSecondarySummary(
281
- metric.name,
282
- best.result.metrics[metric.name],
283
- baselineSecondary[metric.name],
284
- metric.unit,
285
- ),
286
- )
275
+ .map(metric => {
276
+ const v = best.result.metrics[metric.name];
277
+ return v !== undefined
278
+ ? `${metric.name} ${renderSecondaryCell(v, metric.unit, baselineSecondary[metric.name])}`
279
+ : null;
280
+ })
287
281
  .filter((value): value is string => Boolean(value));
288
282
  if (details.length > 0) lines.push(truncateToWidth(`Secondary: ${details.join(" ")}`, width));
289
283
  }
290
284
  }
291
285
  lines.push("");
292
- lines.push(renderTableHeader(state, width, theme));
286
+ const secondaryHeader = state.secondaryMetrics.map(metric => truncateToWidth(metric.name, 10)).join(" ");
287
+ lines.push(
288
+ truncateToWidth(
289
+ `${theme.fg("muted", "#".padEnd(4))}${theme.fg("muted", "commit".padEnd(10))}${theme.fg("warning", state.metricName.padEnd(12))}${secondaryHeader ? `${theme.fg("muted", secondaryHeader)} ` : ""}${theme.fg("muted", "status".padEnd(14))}${theme.fg("muted", "description")}`,
290
+ width,
291
+ ),
292
+ );
293
293
  lines.push(theme.fg("borderMuted", "-".repeat(Math.max(0, width - 1))));
294
294
 
295
295
  const visible = maxRows > 0 ? current.slice(-maxRows) : current;
@@ -300,15 +300,6 @@ function renderDashboardLines(runtime: AutoresearchRuntime, width: number, theme
300
300
  }
301
301
  return lines;
302
302
  }
303
-
304
- function renderTableHeader(state: ExperimentState, width: number, theme: Theme): string {
305
- const secondaryHeader = state.secondaryMetrics.map(metric => truncateToWidth(metric.name, 10)).join(" ");
306
- return truncateToWidth(
307
- `${theme.fg("muted", "#".padEnd(4))}${theme.fg("muted", "commit".padEnd(10))}${theme.fg("warning", state.metricName.padEnd(12))}${secondaryHeader ? `${theme.fg("muted", secondaryHeader)} ` : ""}${theme.fg("muted", "status".padEnd(14))}${theme.fg("muted", "description")}`,
308
- width,
309
- );
310
- }
311
-
312
303
  function renderResultRow(
313
304
  result: ExperimentResult,
314
305
  state: ExperimentState,
@@ -316,7 +307,6 @@ function renderResultRow(
316
307
  width: number,
317
308
  theme: Theme,
318
309
  ): string {
319
- const runNumber = result.runNumber ?? state.results.indexOf(result) + 1;
320
310
  const secondary = state.secondaryMetrics
321
311
  .map(metric =>
322
312
  truncateToWidth(
@@ -327,7 +317,7 @@ function renderResultRow(
327
317
  .join("");
328
318
  const statusColor = result.status === "keep" ? "success" : result.status === "discard" ? "warning" : "error";
329
319
  const line =
330
- theme.fg("dim", String(runNumber).padEnd(4)) +
320
+ theme.fg("dim", String(result.runNumber ?? state.results.indexOf(result) + 1).padEnd(4)) +
331
321
  theme.fg("contentAccent", (result.commit || "-").padEnd(10)) +
332
322
  theme.fg(statusColor, formatNum(result.metric, state.metricUnit).padEnd(12)) +
333
323
  secondary +
@@ -335,47 +325,32 @@ function renderResultRow(
335
325
  theme.fg("muted", replaceTabs(result.description));
336
326
  return truncateToWidth(line, width);
337
327
  }
338
-
339
328
  function renderSecondaryCell(value: number | undefined, unit: string, baseline: number | undefined): string {
340
329
  if (value === undefined) return "-";
341
330
  const formatted = formatNum(value, unit);
342
331
  if (baseline === undefined || baseline === 0 || baseline === value) return formatted;
343
332
  return `${formatted} ${formatDelta(value, baseline)}`;
344
333
  }
345
-
346
- function renderSecondarySummary(
347
- name: string,
348
- value: number | undefined,
349
- baseline: number | undefined,
350
- unit: string,
351
- ): string | null {
352
- if (value === undefined) return null;
353
- return `${name} ${renderSecondaryCell(value, unit, baseline)}`;
354
- }
355
-
356
334
  function formatDelta(value: number, baseline: number): string {
357
335
  const delta = ((value - baseline) / baseline) * 100;
358
336
  return `${delta > 0 ? "+" : ""}${delta.toFixed(1)}%`;
359
337
  }
360
-
361
338
  function renderOverlayRunningLine(
362
339
  runtime: AutoresearchRuntime,
363
340
  theme: Theme,
364
341
  width: number,
365
342
  spinnerFrame: number,
366
343
  ): string {
367
- const spinner = theme.spinnerFrames[spinnerFrame % theme.spinnerFrames.length] ?? "*";
368
344
  return truncateToWidth(
369
345
  theme.fg(
370
346
  "warning",
371
- `${spinner} running ${formatElapsed(Date.now() - (runtime.runningExperiment?.startedAt ?? Date.now()))} ${replaceTabs(
347
+ `${theme.spinnerFrames[spinnerFrame % theme.spinnerFrames.length] ?? "*"} running ${formatElapsed(Date.now() - (runtime.runningExperiment?.startedAt ?? Date.now()))} ${replaceTabs(
372
348
  runtime.runningExperiment?.command ?? "",
373
349
  )}`,
374
350
  ),
375
351
  width,
376
352
  );
377
353
  }
378
-
379
354
  function renderOverlayFooter(
380
355
  width: number,
381
356
  scrollOffset: number,
@@ -391,20 +366,9 @@ function renderOverlayFooter(
391
366
  const fill = Math.max(0, width - visibleWidth(hint));
392
367
  return theme.fg("borderMuted", "-".repeat(fill)) + hint;
393
368
  }
394
-
395
369
  function renderModeStatus(runtime: AutoresearchRuntime, state: ExperimentState): string {
396
370
  if (runtime.autoresearchMode) return state.results.length === 0 ? "baseline pending" : "mode on";
397
371
  const current = currentResults(state.results, state.currentSegment);
398
372
  if (state.maxExperiments !== null && current.length >= state.maxExperiments) return "segment complete";
399
373
  return "mode off";
400
374
  }
401
-
402
- function findBestResult(state: ExperimentState): { index: number; result: ExperimentResult } | null {
403
- let best: { index: number; result: ExperimentResult } | null = null;
404
- for (let index = 0; index < state.results.length; index += 1) {
405
- const result = state.results[index];
406
- if (result.segment !== state.currentSegment || result.status !== "keep" || result.metric <= 0) continue;
407
- if (!best || isBetter(result.metric, best.result.metric, state.bestDirection)) best = { index, result };
408
- }
409
- return best;
410
- }
@@ -4,14 +4,11 @@ import { isAutoresearchLocalStatePath, normalizeAutoresearchPath } from "./helpe
4
4
 
5
5
  const AUTORESEARCH_BRANCH_PREFIX = "autoresearch/";
6
6
  const BRANCH_NAME_MAX_LENGTH = 48;
7
-
8
7
  type EnsureAutoresearchBranchResult = { error: string; ok: false } | { branchName: string; created: boolean; ok: true };
9
-
10
8
  export async function getCurrentAutoresearchBranch(_api: ExtensionAPI, workDir: string): Promise<string | null> {
11
9
  const currentBranch = (await git.branch.current(workDir)) ?? "";
12
10
  return currentBranch.startsWith(AUTORESEARCH_BRANCH_PREFIX) ? currentBranch : null;
13
11
  }
14
-
15
12
  export async function ensureAutoresearchBranch(
16
13
  api: ExtensionAPI,
17
14
  workDir: string,
@@ -68,17 +65,9 @@ export async function ensureAutoresearchBranch(
68
65
  ok: true,
69
66
  };
70
67
  }
71
-
72
68
  export function parseWorkDirDirtyPaths(statusOutput: string, workDirPrefix: string): string[] {
73
- const relativePaths: string[] = [];
74
- for (const dirtyPath of parseDirtyPaths(statusOutput)) {
75
- const relativePath = relativizeGitPathToWorkDir(dirtyPath, workDirPrefix);
76
- if (relativePath === null) continue;
77
- relativePaths.push(relativePath);
78
- }
79
- return relativePaths;
69
+ return parseWorkDirDirtyPathsWithStatus(statusOutput, workDirPrefix).map(e => e.path);
80
70
  }
81
-
82
71
  function relativizeGitPathToWorkDir(repoRelativePath: string, workDirPrefix: string): string | null {
83
72
  const normalizedPath = normalizeStatusPath(repoRelativePath);
84
73
  const normalizedPrefix = normalizeAutoresearchPath(workDirPrefix);
@@ -87,16 +76,11 @@ function relativizeGitPathToWorkDir(repoRelativePath: string, workDirPrefix: str
87
76
  if (!normalizedPath.startsWith(`${normalizedPrefix}/`)) return null;
88
77
  return normalizeAutoresearchPath(normalizedPath.slice(normalizedPrefix.length + 1));
89
78
  }
90
- function parseDirtyPaths(statusOutput: string): string[] {
91
- return parseDirtyPathsWithStatus(statusOutput).map(entry => entry.path);
92
- }
93
-
94
79
  function normalizeStatusPath(path: string): string {
95
80
  let normalized = path.trim();
96
81
  if (normalized.startsWith('"') && normalized.endsWith('"')) normalized = normalized.slice(1, -1);
97
82
  return normalizeAutoresearchPath(normalized);
98
83
  }
99
-
100
84
  async function allocateBranchName(workDir: string, goal: string | null): Promise<string> {
101
85
  const baseName = `${AUTORESEARCH_BRANCH_PREFIX}${slugifyGoal(goal)}-${new Date().toISOString().slice(0, 10).replaceAll("-", "")}`;
102
86
  let candidate = baseName;
@@ -108,49 +92,34 @@ async function allocateBranchName(workDir: string, goal: string | null): Promise
108
92
  return candidate;
109
93
  }
110
94
  function slugifyGoal(goal: string | null): string {
111
- const normalized = (goal ?? "")
95
+ const slug = (goal ?? "")
112
96
  .toLowerCase()
113
97
  .replace(/[^a-z0-9]+/g, "-")
114
98
  .replace(/^-+|-+$/g, "");
115
- const trimmed = normalized.slice(0, BRANCH_NAME_MAX_LENGTH).replace(/-+$/g, "");
116
- return trimmed || "session";
99
+ return slug.slice(0, BRANCH_NAME_MAX_LENGTH).replace(/-+$/g, "") || "session";
117
100
  }
118
101
  function buildUnsafeDirtyPathsFailure(unsafeDirtyPaths: string[]): EnsureAutoresearchBranchResult {
119
- const preview = unsafeDirtyPaths.slice(0, 5).join(", ");
120
- const suffix = unsafeDirtyPaths.length > 5 ? ` (+${unsafeDirtyPaths.length - 5} more)` : "";
121
102
  return {
122
103
  error:
123
104
  "Autoresearch needs a clean git worktree before it can create or reuse an isolated branch. " +
124
- `Commit or stash these paths first: ${preview}${suffix}`,
105
+ `Commit or stash these paths first: ${unsafeDirtyPaths.slice(0, 5).join(", ")}${unsafeDirtyPaths.length > 5 ? ` (+${unsafeDirtyPaths.length - 5} more)` : ""}`,
125
106
  ok: false,
126
107
  };
127
108
  }
128
-
129
- function isRenameOrCopy(statusToken: string): boolean {
130
- const trimmed = statusToken.trim();
131
- return trimmed.startsWith("R") || trimmed.startsWith("C");
132
- }
133
-
134
109
  function collectUnsafeDirtyPaths(statusOutput: string, workDirPrefix: string): string[] {
135
- const unsafeDirtyPaths: string[] = [];
136
- for (const dirtyPath of parseDirtyPaths(statusOutput)) {
137
- const relativePath = relativizeGitPathToWorkDir(dirtyPath, workDirPrefix);
138
- if (relativePath && isAutoresearchLocalStatePath(relativePath)) continue;
139
- unsafeDirtyPaths.push(relativePath ?? normalizeStatusPath(dirtyPath));
140
- }
141
- return unsafeDirtyPaths;
110
+ return parseDirtyPathsWithStatus(statusOutput)
111
+ .map(entry => ({ rel: relativizeGitPathToWorkDir(entry.path, workDirPrefix), raw: entry.path }))
112
+ .filter(({ rel }) => !(rel && isAutoresearchLocalStatePath(rel)))
113
+ .map(({ rel, raw }) => rel ?? normalizeStatusPath(raw));
142
114
  }
143
-
144
115
  interface DirtyPathEntry {
145
116
  path: string;
146
117
  untracked: boolean;
147
118
  }
148
-
149
119
  function parseDirtyPathsWithStatus(statusOutput: string): DirtyPathEntry[] {
150
120
  if (statusOutput.includes("\0")) return parseDirtyPathsNulWithStatus(statusOutput);
151
121
  return parseDirtyPathsLinesWithStatus(statusOutput);
152
122
  }
153
-
154
123
  function parseDirtyPathsNulWithStatus(statusOutput: string): DirtyPathEntry[] {
155
124
  const seen = new Set<string>();
156
125
  const results: DirtyPathEntry[] = [];
@@ -162,9 +131,9 @@ function parseDirtyPathsNulWithStatus(statusOutput: string): DirtyPathEntry[] {
162
131
  if (pathEnd < 0) break;
163
132
  const firstPath = statusOutput.slice(index, pathEnd);
164
133
  index = pathEnd + 1;
165
- const untracked = statusToken.trim().startsWith("??");
166
- addDirtyPathEntry(seen, results, firstPath, untracked);
167
- if (isRenameOrCopy(statusToken)) {
134
+ const statusCode = statusToken.trim();
135
+ addDirtyPathEntry(seen, results, firstPath, statusCode.startsWith("??"));
136
+ if (statusCode.startsWith("R") || statusCode.startsWith("C")) {
168
137
  const secondPathEnd = statusOutput.indexOf("\0", index);
169
138
  if (secondPathEnd < 0) break;
170
139
  const secondPath = statusOutput.slice(index, secondPathEnd);
@@ -174,7 +143,6 @@ function parseDirtyPathsNulWithStatus(statusOutput: string): DirtyPathEntry[] {
174
143
  }
175
144
  return results;
176
145
  }
177
-
178
146
  function parseDirtyPathsLinesWithStatus(statusOutput: string): DirtyPathEntry[] {
179
147
  const seen = new Set<string>();
180
148
  const results: DirtyPathEntry[] = [];
@@ -192,14 +160,12 @@ function parseDirtyPathsLinesWithStatus(statusOutput: string): DirtyPathEntry[]
192
160
  }
193
161
  return results;
194
162
  }
195
-
196
163
  function addDirtyPathEntry(seen: Set<string>, results: DirtyPathEntry[], rawPath: string, untracked: boolean): void {
197
164
  const normalizedPath = normalizeStatusPath(rawPath);
198
165
  if (normalizedPath.length === 0 || seen.has(normalizedPath)) return;
199
166
  seen.add(normalizedPath);
200
167
  results.push({ path: normalizedPath, untracked });
201
168
  }
202
-
203
169
  export function parseWorkDirDirtyPathsWithStatus(statusOutput: string, workDirPrefix: string): DirtyPathEntry[] {
204
170
  const results: DirtyPathEntry[] = [];
205
171
  for (const entry of parseDirtyPathsWithStatus(statusOutput)) {
@@ -209,19 +175,17 @@ export function parseWorkDirDirtyPathsWithStatus(statusOutput: string, workDirPr
209
175
  }
210
176
  return results;
211
177
  }
212
-
213
178
  export function computeRunModifiedPaths(
214
179
  preRunDirtyPaths: string[],
215
180
  currentStatusOutput: string,
216
181
  workDirPrefix: string,
217
182
  ): { tracked: string[]; untracked: string[] } {
218
183
  const preRunSet = new Set(preRunDirtyPaths);
219
- const tracked: string[] = [];
220
- const untracked: string[] = [];
221
- for (const entry of parseWorkDirDirtyPathsWithStatus(currentStatusOutput, workDirPrefix)) {
222
- if (preRunSet.has(entry.path)) continue;
223
- if (isAutoresearchLocalStatePath(entry.path)) continue;
224
- (entry.untracked ? untracked : tracked).push(entry.path);
225
- }
226
- return { tracked, untracked };
184
+ const entries = parseWorkDirDirtyPathsWithStatus(currentStatusOutput, workDirPrefix).filter(
185
+ e => !preRunSet.has(e.path) && !isAutoresearchLocalStatePath(e.path),
186
+ );
187
+ return {
188
+ tracked: entries.filter(e => !e.untracked).map(e => e.path),
189
+ untracked: entries.filter(e => e.untracked).map(e => e.path),
190
+ };
227
191
  }