@dailephd/my-dev-kit-lab 0.7.1 → 0.8.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 (59) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +10 -5
  3. package/benchmarks/contracts/warm-index-benchmark-cases.json +330 -0
  4. package/dist/src/cli/help.js +27 -4
  5. package/dist/src/commands/runExperimentDescribeCommand.js +13 -2
  6. package/dist/src/commands/runExperimentRunCommand.js +210 -20
  7. package/dist/src/core/fileGlobs.js +44 -0
  8. package/dist/src/evaluation/benchmarkMetadata.js +13 -0
  9. package/dist/src/evaluation/localRepositorySubject/config.js +296 -0
  10. package/dist/src/evaluation/localRepositorySubject/evaluationCase.js +36 -0
  11. package/dist/src/evaluation/localRepositorySubject/expectedFiles.js +51 -0
  12. package/dist/src/evaluation/localRepositorySubject/gitRepository.js +128 -0
  13. package/dist/src/evaluation/localRepositorySubject/index.js +8 -0
  14. package/dist/src/evaluation/localRepositorySubject/inventory.js +127 -0
  15. package/dist/src/evaluation/localRepositorySubject/loadLocalRepositorySubject.js +48 -0
  16. package/dist/src/evaluation/localRepositorySubject/manifest.js +52 -0
  17. package/dist/src/evaluation/localRepositorySubject/types.js +55 -0
  18. package/dist/src/evaluation/projectFileTree.js +45 -1
  19. package/dist/src/evaluation/retrievalQuality/buildRetrievalEvidence.js +292 -0
  20. package/dist/src/evaluation/retrievalQuality/corpusCompleteness.js +122 -0
  21. package/dist/src/evaluation/retrievalQuality/factContextTargets.js +44 -0
  22. package/dist/src/evaluation/retrievalQuality/index.js +5 -0
  23. package/dist/src/evaluation/retrievalQuality/metrics.js +233 -0
  24. package/dist/src/evaluation/retrievalQuality/types.js +9 -0
  25. package/dist/src/evaluation/runMyDevKitRetrieval.js +6 -1
  26. package/dist/src/evaluation/runRawFullFileBaseline.js +18 -4
  27. package/dist/src/evaluation/targetImmutability/captureTargetSnapshot.js +29 -7
  28. package/dist/src/evaluation/targetImmutability/compareTargetSnapshots.js +4 -0
  29. package/dist/src/experiments/defaultRegistry.js +2 -0
  30. package/dist/src/experiments/plugins/contextWindowScaling/localSubjectErrors.js +35 -0
  31. package/dist/src/experiments/plugins/contextWindowScaling/localSubjectExclusions.js +75 -0
  32. package/dist/src/experiments/plugins/contextWindowScaling/localSubjectExecution.js +136 -0
  33. package/dist/src/experiments/plugins/contextWindowScaling/localSubjectPrivacy.js +128 -0
  34. package/dist/src/experiments/plugins/contextWindowScaling/localSubjectScratch.js +85 -0
  35. package/dist/src/experiments/plugins/contextWindowScaling/metadata.js +2 -2
  36. package/dist/src/experiments/plugins/contextWindowScaling/plugin.js +93 -0
  37. package/dist/src/experiments/plugins/contextWindowScaling/projectProfile.js +4 -9
  38. package/dist/src/experiments/plugins/index.js +1 -0
  39. package/dist/src/experiments/plugins/retrievalPrecisionRecall/config.js +39 -0
  40. package/dist/src/experiments/plugins/retrievalPrecisionRecall/execution.js +152 -0
  41. package/dist/src/experiments/plugins/retrievalPrecisionRecall/executionArtifact.js +15 -0
  42. package/dist/src/experiments/plugins/retrievalPrecisionRecall/index.js +9 -0
  43. package/dist/src/experiments/plugins/retrievalPrecisionRecall/localSubjectExecution.js +139 -0
  44. package/dist/src/experiments/plugins/retrievalPrecisionRecall/localSubjectPrivacy.js +119 -0
  45. package/dist/src/experiments/plugins/retrievalPrecisionRecall/metadata.js +21 -0
  46. package/dist/src/experiments/plugins/retrievalPrecisionRecall/metrics.js +158 -0
  47. package/dist/src/experiments/plugins/retrievalPrecisionRecall/plugin.js +253 -0
  48. package/dist/src/experiments/plugins/retrievalPrecisionRecall/types.js +8 -0
  49. package/dist/src/report/experiments/buildPluginExperimentReport.js +21 -2
  50. package/dist/src/report/experiments/buildRetrievalPrecisionRecallReport.js +83 -0
  51. package/dist/src/report/experiments/index.js +4 -0
  52. package/dist/src/report/experiments/renderPluginExperimentReportHtml.js +7 -3
  53. package/dist/src/report/experiments/renderPluginExperimentReportText.js +9 -0
  54. package/dist/src/report/experiments/renderRetrievalPrecisionRecallHtml.js +96 -0
  55. package/dist/src/report/experiments/renderRetrievalPrecisionRecallText.js +121 -0
  56. package/dist/src/report/experiments/retrievalPrecisionRecallReportModel.js +3 -0
  57. package/dist/src/report/experiments/writePluginExperimentReports.js +8 -2
  58. package/docs/METRICS.md +34 -2
  59. package/package.json +1 -1
@@ -3,8 +3,12 @@ import path from "node:path";
3
3
  import { parseAgentCommandTemplate } from "../agents/index.js";
4
4
  import { parseAgentId } from "../agents/agentRegistry.js";
5
5
  import { readBenchmarkProjectProfiles, readEvaluationCases } from "../evaluation/index.js";
6
- import { contextStrategyComparisonPlugin, contextWindowScalingPlugin, createDefaultExperimentPluginRegistry, getWarmIndexCampaignPreset, incrementalChangeStalenessPlugin, parseContextBudgetsCliValue, parseWarmIndexCampaignPresetId, resolveExperimentTarget, runExperiment, warmIndexReusePlugin } from "../experiments/index.js";
6
+ import { summarizeRetrievalGroundTruthIssuesSafely, validateRetrievalPrecisionRecallCorpus } from "../evaluation/retrievalQuality/index.js";
7
+ import { loadLocalRepositorySubject } from "../evaluation/localRepositorySubject/index.js";
8
+ import { contextStrategyComparisonPlugin, contextWindowScalingPlugin, createDefaultExperimentPluginRegistry, getWarmIndexCampaignPreset, incrementalChangeStalenessPlugin, parseContextBudgetsCliValue, parseWarmIndexCampaignPresetId, resolveExperimentTarget, retrievalPrecisionRecallPlugin, runExperiment, warmIndexReusePlugin } from "../experiments/index.js";
7
9
  import { prepareSyntheticContextWindowScalingInputs } from "../experiments/plugins/contextWindowScaling/index.js";
10
+ import { projectExternalLocalTarget, projectRunForExternalLocalPersistence, } from "../experiments/plugins/contextWindowScaling/localSubjectPrivacy.js";
11
+ import { assertWorkRootOutsideTarget } from "../experiments/plugins/contextWindowScaling/localSubjectScratch.js";
8
12
  import { buildDefaultExperimentOutputRoot } from "../experiments/outputPaths.js";
9
13
  import { parsePromptComplexityLevel, parsePromptStrategy } from "../prompts/index.js";
10
14
  import { writePluginExperimentReports } from "../report/index.js";
@@ -38,7 +42,23 @@ const DEFAULT_PROJECT_PROFILES_RESOURCE = "benchmarks/contracts/benchmark-projec
38
42
  // catalog's package-relative targetRoot, so both resolve from the package root, never the cwd.
39
43
  const CONTEXT_WINDOW_SCALING_CASES_RESOURCE = "benchmarks/contracts/context-window-scaling-cases.json";
40
44
  // Options the context-window-scaling plugin accepts; every other flag is rejected, not ignored.
41
- const CONTEXT_WINDOW_SCALING_ALLOWED_FLAGS = ["--experiment", "--out", "--case", "--synthetic-config", "--context-budgets", "--kit-command"];
45
+ const CONTEXT_WINDOW_SCALING_ALLOWED_FLAGS = [
46
+ "--experiment",
47
+ "--out",
48
+ "--target",
49
+ "--case",
50
+ "--synthetic-config",
51
+ "--local-subject-config",
52
+ "--context-budgets",
53
+ "--kit-command"
54
+ ];
55
+ // retrieval-precision-recall owns one frozen bundled corpus (the warm-index benchmark cases) and its project profiles,
56
+ // both resolved from the package root, never the cwd. It accepts only these options; every other flag is rejected.
57
+ const RETRIEVAL_PRECISION_RECALL_CASES_RESOURCE = "benchmarks/contracts/warm-index-benchmark-cases.json";
58
+ const RETRIEVAL_PRECISION_RECALL_ALLOWED_FLAGS = ["--experiment", "--out", "--case", "--benchmark-project", "--kit-command"];
59
+ // External-local mode (--target with --local-subject-config) is a different subject source: the local-subject config owns
60
+ // the case set, so the bundled corpus filters are not accepted there.
61
+ const RETRIEVAL_PRECISION_RECALL_EXTERNAL_ALLOWED_FLAGS = ["--experiment", "--out", "--target", "--local-subject-config", "--kit-command"];
42
62
  export async function runExperimentRunCommandFromArgs(argv, options = {}) {
43
63
  try {
44
64
  const args = parseRunExperimentArgs(argv);
@@ -53,8 +73,8 @@ export async function runExperimentRunCommandFromArgs(argv, options = {}) {
53
73
  experimentId: args.experimentId,
54
74
  runId
55
75
  });
56
- if (args.syntheticConfigPath !== undefined && outputRoot === undefined) {
57
- // Synthetic generation needs the concrete root before the runner runs; use the exact default the
76
+ if ((args.syntheticConfigPath !== undefined || args.localSubjectConfigPath !== undefined) && outputRoot === undefined) {
77
+ // Synthetic generation and external-local runs need the concrete root before the runner runs; use the exact default the
58
78
  // runner itself would compute for this self-targeted invocation.
59
79
  outputRoot = buildDefaultExperimentOutputRoot({
60
80
  toolRoot,
@@ -74,22 +94,53 @@ export async function runExperimentRunCommandFromArgs(argv, options = {}) {
74
94
  toolRoot,
75
95
  runId
76
96
  });
97
+ const localSubject = readLoadedLocalSubject(inputs);
98
+ // External-local runs persist only a projected run: no target, tool, or output machine-local paths.
99
+ const persistedRun = localSubject
100
+ ? projectRunForExternalLocalPersistence(result, projectExternalLocalTarget(localSubject.manifest))
101
+ : result;
102
+ if (localSubject && result.status === "failed") {
103
+ // A failed local run has no execution evidence, so no report is produced and none could be mistaken for a
104
+ // completed experiment. The safe failure description (codes and kinds only) is the whole outcome.
105
+ console.log([
106
+ `Experiment: ${result.pluginId}`,
107
+ `Run ID: ${result.runId}`,
108
+ "Status: failed",
109
+ "Mode: external-local repository subject",
110
+ `Subject: ${localSubject.subjectId}`,
111
+ ...result.failures.map((failure) => `Failure: ${failure.message}`)
112
+ ].join("\n"));
113
+ return 1;
114
+ }
77
115
  const reports = await writePluginExperimentReports({
78
- run: result,
116
+ run: persistedRun,
79
117
  plugin: registry.describe(result.pluginId),
80
- outputRoot: String(result.metadata?.outputRoot ?? "")
118
+ outputRoot: String(result.metadata?.outputRoot ?? ""),
119
+ redactOutputRoot: localSubject !== undefined
81
120
  });
82
- const outputLines = [
83
- `Experiment: ${result.pluginId}`,
84
- `Run ID: ${result.runId}`,
85
- `Status: ${result.status}`,
86
- `Mode: ${result.target.isSelf ? "self" : "external target"}`,
87
- `Tool root: ${result.target.toolRoot}`,
88
- `Target root: ${result.target.targetRoot}`,
89
- `Output: ${String(result.metadata?.outputRoot ?? "")}`,
90
- `Report JSON: ${reports.outputPaths.jsonPath}`,
91
- `Report HTML: ${reports.outputPaths.htmlPath}`
92
- ];
121
+ const outputLines = localSubject
122
+ ? [
123
+ `Experiment: ${result.pluginId}`,
124
+ `Run ID: ${result.runId}`,
125
+ `Status: ${result.status}`,
126
+ "Mode: external-local repository subject",
127
+ `Subject: ${localSubject.subjectId}`,
128
+ `Output: ${String(result.metadata?.outputRoot ?? "")}`,
129
+ `Report JSON: ${reports.outputPaths.jsonPath}`,
130
+ `Report HTML: ${reports.outputPaths.htmlPath}`,
131
+ ...result.failures.map((failure) => `Failure: ${failure.message}`)
132
+ ]
133
+ : [
134
+ `Experiment: ${result.pluginId}`,
135
+ `Run ID: ${result.runId}`,
136
+ `Status: ${result.status}`,
137
+ `Mode: ${result.target.isSelf ? "self" : "external target"}`,
138
+ `Tool root: ${result.target.toolRoot}`,
139
+ `Target root: ${result.target.targetRoot}`,
140
+ `Output: ${String(result.metadata?.outputRoot ?? "")}`,
141
+ `Report JSON: ${reports.outputPaths.jsonPath}`,
142
+ `Report HTML: ${reports.outputPaths.htmlPath}`
143
+ ];
93
144
  // Automatic presentation (report already produced above) applies only to warm-index-reuse
94
145
  // campaign runs; legacy fake-agent warm-index runs and context-strategy-comparison stay
95
146
  // report-only, unchanged.
@@ -161,6 +212,7 @@ export function parseRunExperimentArgs(argv) {
161
212
  let campaignPreset;
162
213
  let contextBudgets;
163
214
  let syntheticConfigPath;
215
+ let localSubjectConfigPath;
164
216
  const seenFlags = [];
165
217
  const commandTemplates = {};
166
218
  for (let index = 0; index < argv.length; index += 1) {
@@ -188,6 +240,12 @@ export function parseRunExperimentArgs(argv) {
188
240
  }
189
241
  syntheticConfigPath = readRequiredValue(argv, ++index, "--synthetic-config");
190
242
  }
243
+ else if (arg === "--local-subject-config") {
244
+ if (localSubjectConfigPath !== undefined) {
245
+ throw new Error("--local-subject-config may be supplied only once.");
246
+ }
247
+ localSubjectConfigPath = readRequiredValue(argv, ++index, "--local-subject-config");
248
+ }
191
249
  else if (arg === "--case") {
192
250
  caseIds.push(...splitList(readRequiredValue(argv, ++index, "--case")));
193
251
  }
@@ -250,14 +308,44 @@ export function parseRunExperimentArgs(argv) {
250
308
  const KIT_COMMAND_PLUGIN_IDS = [
251
309
  warmIndexReusePlugin.metadata.id,
252
310
  incrementalChangeStalenessPlugin.metadata.id,
253
- contextWindowScalingPlugin.metadata.id
311
+ contextWindowScalingPlugin.metadata.id,
312
+ retrievalPrecisionRecallPlugin.metadata.id
254
313
  ];
314
+ if (experimentId === retrievalPrecisionRecallPlugin.metadata.id) {
315
+ // Mode matrix, no precedence rules: neither flag = bundled; both = external-local; exactly one is rejected.
316
+ if (targetPath !== undefined && localSubjectConfigPath === undefined) {
317
+ throw new Error(`External ${experimentId} targets require --local-subject-config.`);
318
+ }
319
+ if (localSubjectConfigPath !== undefined && targetPath === undefined) {
320
+ throw new Error(`--local-subject-config requires an external --target for ${experimentId}.`);
321
+ }
322
+ const externalMode = targetPath !== undefined;
323
+ if (externalMode && (seenFlags.includes("--case") || seenFlags.includes("--benchmark-project"))) {
324
+ throw new Error("--case and --benchmark-project cannot be combined with --local-subject-config; the local subject config owns the case set.");
325
+ }
326
+ const allowedFlags = externalMode ? RETRIEVAL_PRECISION_RECALL_EXTERNAL_ALLOWED_FLAGS : RETRIEVAL_PRECISION_RECALL_ALLOWED_FLAGS;
327
+ const unsupported = [...new Set(seenFlags.filter((flag) => !allowedFlags.includes(flag)))];
328
+ if (unsupported.length > 0) {
329
+ throw new Error(`${unsupported.join(", ")} ${unsupported.length === 1 ? "is" : "are"} not supported for --experiment ${experimentId}${externalMode ? " in external-local mode" : ""}; supported options: ${allowedFlags.join(", ")}.`);
330
+ }
331
+ if (seenFlags.includes("--case") && caseIds.length === 0) {
332
+ throw new Error("--case must list at least one case id.");
333
+ }
334
+ if (seenFlags.includes("--benchmark-project") && benchmarkProjects.length === 0) {
335
+ throw new Error("--benchmark-project must list at least one benchmark project id.");
336
+ }
337
+ }
255
338
  if (contextBudgets !== undefined && experimentId !== contextWindowScalingPlugin.metadata.id) {
256
339
  throw new Error(`--context-budgets is only supported for --experiment ${contextWindowScalingPlugin.metadata.id}.`);
257
340
  }
258
341
  if (syntheticConfigPath !== undefined && experimentId !== contextWindowScalingPlugin.metadata.id) {
259
342
  throw new Error(`--synthetic-config is only supported for --experiment ${contextWindowScalingPlugin.metadata.id}.`);
260
343
  }
344
+ if (localSubjectConfigPath !== undefined &&
345
+ experimentId !== contextWindowScalingPlugin.metadata.id &&
346
+ experimentId !== retrievalPrecisionRecallPlugin.metadata.id) {
347
+ throw new Error(`--local-subject-config is only supported for --experiment ${contextWindowScalingPlugin.metadata.id} or ${retrievalPrecisionRecallPlugin.metadata.id}.`);
348
+ }
261
349
  if (experimentId === contextWindowScalingPlugin.metadata.id) {
262
350
  const unsupported = [...new Set(seenFlags.filter((flag) => !CONTEXT_WINDOW_SCALING_ALLOWED_FLAGS.includes(flag)))];
263
351
  if (unsupported.length > 0) {
@@ -269,6 +357,23 @@ export function parseRunExperimentArgs(argv) {
269
357
  if (syntheticConfigPath !== undefined && seenFlags.includes("--case")) {
270
358
  throw new Error("--case and --synthetic-config are mutually exclusive; the synthetic config owns the generated case set.");
271
359
  }
360
+ // Subject-mode matrix: bundled (no source flags), synthetic (--synthetic-config), external-local
361
+ // (--target with --local-subject-config). Every other combination is rejected, never given a precedence.
362
+ if (localSubjectConfigPath !== undefined && syntheticConfigPath !== undefined) {
363
+ throw new Error("--synthetic-config and --local-subject-config are mutually exclusive; choose one subject source.");
364
+ }
365
+ if (localSubjectConfigPath !== undefined && seenFlags.includes("--case")) {
366
+ throw new Error("--case cannot be combined with --local-subject-config; the local subject config owns the case set.");
367
+ }
368
+ if (localSubjectConfigPath !== undefined && targetPath === undefined) {
369
+ throw new Error(`--local-subject-config requires an external --target for ${experimentId}.`);
370
+ }
371
+ if (syntheticConfigPath !== undefined && targetPath !== undefined) {
372
+ throw new Error("--synthetic-config cannot be combined with --target; synthetic subjects are generated beneath the experiment output directory.");
373
+ }
374
+ if (targetPath !== undefined && localSubjectConfigPath === undefined) {
375
+ throw new Error(`External ${experimentId} targets require --local-subject-config.`);
376
+ }
272
377
  }
273
378
  if (kitCommand !== undefined && !KIT_COMMAND_PLUGIN_IDS.includes(experimentId)) {
274
379
  throw new Error(`--kit-command is only supported for --experiment ${KIT_COMMAND_PLUGIN_IDS.join(" or ")}.`);
@@ -292,6 +397,7 @@ export function parseRunExperimentArgs(argv) {
292
397
  targetPath,
293
398
  outDir,
294
399
  syntheticConfigPath,
400
+ localSubjectConfigPath,
295
401
  config: withoutUndefined({
296
402
  casesPath,
297
403
  projectProfilesPath,
@@ -355,8 +461,64 @@ async function loadPluginInputs(args, toolRoot, context, outputRoot) {
355
461
  if (args.experimentId === contextWindowScalingPlugin.metadata.id) {
356
462
  return loadContextWindowScalingInputs(args, toolRoot, context, outputRoot);
357
463
  }
464
+ if (args.experimentId === retrievalPrecisionRecallPlugin.metadata.id) {
465
+ return args.localSubjectConfigPath !== undefined
466
+ ? loadRetrievalPrecisionRecallLocalSubjectInputs(args, toolRoot, context, outputRoot)
467
+ : loadRetrievalPrecisionRecallInputs(args, toolRoot, context);
468
+ }
358
469
  return undefined;
359
470
  }
471
+ // External-local retrieval-precision-recall: the existing local-subject loader owns the repository, inventory and case
472
+ // adapter. The only additions are the physical output-root check before anything is created and a ground-truth gate
473
+ // whose message carries logical case ids and counts only (an external subject may have private fact ids and names).
474
+ async function loadRetrievalPrecisionRecallLocalSubjectInputs(args, toolRoot, context, outputRoot) {
475
+ const validation = retrievalPrecisionRecallPlugin.validateConfig(args.config);
476
+ if (!validation.valid || !validation.config) {
477
+ throw new Error(`Invalid retrieval precision/recall config: ${validation.errors.join("; ")}`);
478
+ }
479
+ if (outputRoot === undefined || args.localSubjectConfigPath === undefined) {
480
+ throw new Error("Internal error: external-local runs require a resolved experiment output root and config path.");
481
+ }
482
+ const target = resolveExperimentTarget(args.targetPath, toolRoot);
483
+ if (target.isSelf || target.kind !== "external-local") {
484
+ throw new Error("Local-repository subject mode requires an external-local target; --target must not be the Lab repository.");
485
+ }
486
+ // Fail closed before anything is created: the output root (and the private scratch beneath it) must be physically outside the target.
487
+ try {
488
+ await assertWorkRootOutsideTarget(outputRoot, target.targetRoot);
489
+ }
490
+ catch {
491
+ throw new Error("Experiment output root must not be inside the external target project.");
492
+ }
493
+ const configPath = path.resolve(context.invocationCwd, args.localSubjectConfigPath);
494
+ const localSubjectConfig = await readJsonConfigFile("--local-subject-config", configPath, args.localSubjectConfigPath);
495
+ const localSubject = await loadLocalRepositorySubject({ config: localSubjectConfig, repositoryPath: target.targetRoot });
496
+ const groundTruthIssues = summarizeRetrievalGroundTruthIssuesSafely(localSubject.evaluationCases);
497
+ if (groundTruthIssues.length > 0) {
498
+ throw new Error(`Invalid retrieval-precision-recall ground truth for the local subject (${groundTruthIssues
499
+ .map((issue) => `case ${issue.caseId}: ${issue.issueCount} issue${issue.issueCount === 1 ? "" : "s"}`)
500
+ .join("; ")}); details withheld.`);
501
+ }
502
+ return { cases: localSubject.evaluationCases, localSubject, env: process.env };
503
+ }
504
+ // The frozen bundled corpus is read through the established resource resolver and validation path, then must satisfy
505
+ // the retrieval-precision-recall ground-truth completeness rules. Selection filters are applied by the plugin.
506
+ async function loadRetrievalPrecisionRecallInputs(args, toolRoot, context) {
507
+ const validation = retrievalPrecisionRecallPlugin.validateConfig(args.config);
508
+ if (!validation.valid || !validation.config) {
509
+ throw new Error(`Invalid retrieval precision/recall config: ${validation.errors.join("; ")}`);
510
+ }
511
+ const projectProfiles = await readBenchmarkProjectProfiles(resolvePackageResource(context, DEFAULT_PROJECT_PROFILES_RESOURCE), toolRoot);
512
+ const cases = await readEvaluationCases(resolvePackageResource(context, RETRIEVAL_PRECISION_RECALL_CASES_RESOURCE), toolRoot, {
513
+ projectProfiles,
514
+ requireProjectProfileRef: true
515
+ });
516
+ const corpusErrors = validateRetrievalPrecisionRecallCorpus(cases);
517
+ if (corpusErrors.length > 0) {
518
+ throw new Error(`Invalid retrieval-precision-recall corpus: ${corpusErrors.join(" ")}`);
519
+ }
520
+ return { cases };
521
+ }
360
522
  // --case is a selection over the frozen bundled catalog (catalog order, not argument order);
361
523
  // the rest of the CLI config is the plugin's own ContextWindowScalingConfig.
362
524
  function pluginConfigFor(args) {
@@ -370,6 +532,27 @@ async function loadContextWindowScalingInputs(args, toolRoot, context, outputRoo
370
532
  if (!validation.valid || !validation.config) {
371
533
  throw new Error(`Invalid context window scaling config: ${validation.errors.join("; ")}`);
372
534
  }
535
+ if (args.localSubjectConfigPath !== undefined) {
536
+ if (outputRoot === undefined) {
537
+ throw new Error("Internal error: external-local runs require a resolved experiment output root.");
538
+ }
539
+ const target = resolveExperimentTarget(args.targetPath, toolRoot);
540
+ if (target.isSelf || target.kind !== "external-local") {
541
+ throw new Error("Local-repository subject mode requires an external-local target; --target must not be the Lab repository.");
542
+ }
543
+ // Fail closed before anything is created: the output root (and the private scratch beneath it) must be outside the target.
544
+ try {
545
+ await assertWorkRootOutsideTarget(outputRoot, target.targetRoot);
546
+ }
547
+ catch {
548
+ throw new Error("Experiment output root must not be inside the external target project.");
549
+ }
550
+ const configPath = path.resolve(context.invocationCwd, args.localSubjectConfigPath);
551
+ const localSubjectConfig = await readJsonConfigFile("--local-subject-config", configPath, args.localSubjectConfigPath);
552
+ // The selected --target is the physical root handed to the Batch 1 loader; the config carries no path.
553
+ const localSubject = await loadLocalRepositorySubject({ config: localSubjectConfig, repositoryPath: target.targetRoot });
554
+ return { cases: localSubject.evaluationCases, localSubject, env: process.env };
555
+ }
373
556
  if (args.syntheticConfigPath !== undefined) {
374
557
  if (outputRoot === undefined) {
375
558
  throw new Error("Internal error: synthetic generation requires a resolved experiment output root.");
@@ -396,20 +579,27 @@ async function loadContextWindowScalingInputs(args, toolRoot, context, outputRoo
396
579
  }
397
580
  // The command only reads and parses the caller-supplied file; schema validation belongs to the synthetic planner.
398
581
  async function readSyntheticConfigJson(resolvedPath, displayPath) {
582
+ return readJsonConfigFile("--synthetic-config", resolvedPath, displayPath);
583
+ }
584
+ async function readJsonConfigFile(flag, resolvedPath, displayPath) {
399
585
  let text;
400
586
  try {
401
587
  text = await readFile(resolvedPath, "utf8");
402
588
  }
403
589
  catch (error) {
404
- throw new Error(`Cannot read --synthetic-config ${displayPath}: ${error instanceof Error ? error.message : String(error)}`);
590
+ throw new Error(`Cannot read ${flag} ${displayPath}: ${error instanceof Error ? error.message : String(error)}`);
405
591
  }
406
592
  try {
407
593
  return JSON.parse(text);
408
594
  }
409
595
  catch (error) {
410
- throw new Error(`--synthetic-config ${displayPath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
596
+ throw new Error(`${flag} ${displayPath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
411
597
  }
412
598
  }
599
+ function readLoadedLocalSubject(inputs) {
600
+ const value = inputs?.localSubject;
601
+ return value && typeof value === "object" ? value : undefined;
602
+ }
413
603
  // Shared by both registered plugins: explicit paths resolve against toolRoot, defaults come from
414
604
  // bundled package resources so they do not depend on the invocation cwd.
415
605
  async function loadCasesAndProjectProfiles(args, toolRoot, context) {
@@ -98,3 +98,47 @@ export function collectFilesForGlobs(targetRoot, globs) {
98
98
  .sort(([a], [b]) => a.localeCompare(b))
99
99
  .map(([relativePath, absolutePath]) => ({ relativePath, absolutePath }));
100
100
  }
101
+ /**
102
+ * Pure counterpart of `collectFilesForGlobs` for a caller-supplied, already validated list of repository-relative
103
+ * file paths. It applies the same glob matching and the same walk exclusions (excluded directory names below the
104
+ * glob base directory, temp files) and the same ordering, but never touches the filesystem, so the caller decides
105
+ * which files may be opened at all. Unlike the walking variant it does not require the base directory to exist.
106
+ */
107
+ export function selectRelativePathsForGlobs(relativePaths, globs) {
108
+ const selected = new Set();
109
+ for (const globPattern of globs) {
110
+ if (!globPattern || typeof globPattern !== "string") {
111
+ throw new Error("Invalid glob pattern.");
112
+ }
113
+ const baseDir = baseDirectoryFromGlob(globPattern);
114
+ if (baseDir.split("/").includes("..") || path.isAbsolute(baseDir)) {
115
+ throw new Error(`Resolved path escapes target root: ${globPattern}`);
116
+ }
117
+ // Linear scan for the trailing "/" run (a regular expression here is a polynomial-backtracking risk on
118
+ // glob text that comes from user configuration).
119
+ let baseEnd = baseDir.length;
120
+ while (baseEnd > 0 && baseDir.charCodeAt(baseEnd - 1) === 47)
121
+ baseEnd -= 1;
122
+ const normalizedBase = baseDir === "." ? "" : baseDir.slice(0, baseEnd);
123
+ for (const relativePath of relativePaths) {
124
+ const normalizedPath = relativePath.replace(/\\/g, "/");
125
+ if (normalizedPath === normalizedBase) {
126
+ // An exact file base is used as-is by the walking variant (no walk exclusions apply).
127
+ if (matchesGlob(normalizedPath, globPattern))
128
+ selected.add(normalizedPath);
129
+ continue;
130
+ }
131
+ if (normalizedBase !== "" && !normalizedPath.startsWith(`${normalizedBase}/`))
132
+ continue;
133
+ const belowBase = normalizedBase === "" ? normalizedPath : normalizedPath.slice(normalizedBase.length + 1);
134
+ const directorySegments = belowBase.split("/").slice(0, -1);
135
+ if (directorySegments.some((segment) => excludedDirNames.has(segment)))
136
+ continue;
137
+ if (isTempFile(normalizedPath))
138
+ continue;
139
+ if (matchesGlob(normalizedPath, globPattern))
140
+ selected.add(normalizedPath);
141
+ }
142
+ }
143
+ return [...selected].sort((a, b) => a.localeCompare(b));
144
+ }
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { PROJECT_COMPLEXITY_FORMULA, calculateProjectComplexityScore, roundToTwo } from "./projectComplexity.js";
5
+ import { hasFactMapping, interpretFactContextTarget } from "./retrievalQuality/factContextTargets.js";
5
6
  import { TASK_LOCALITIES } from "./types.js";
6
7
  export const REQUIRED_BENCHMARK_PROJECT_IDS = [
7
8
  "todo-ts",
@@ -90,6 +91,18 @@ export function validateAnswerKey(answerKey, label) {
90
91
  errors.push(`${label}: answerKey.minimumCorrectFacts is not satisfiable.`);
91
92
  }
92
93
  }
94
+ if (Array.isArray(candidate.expectedContextTargets) && Array.isArray(candidate.expectedFacts)) {
95
+ // Only targets that opt into fact mapping with `factIds` are validated here; legacy targets stay valid as-is.
96
+ const knownFactIds = new Set(candidate.expectedFacts.flatMap((fact) => (fact && typeof fact === "object" && typeof fact.id === "string" && fact.id.length > 0 ? [fact.id] : [])));
97
+ candidate.expectedContextTargets.forEach((target, index) => {
98
+ if (!hasFactMapping(target))
99
+ return;
100
+ const interpretation = interpretFactContextTarget(target, knownFactIds);
101
+ if (!interpretation.ok) {
102
+ errors.push(`${label}: answerKey.expectedContextTargets[${index}]: ${interpretation.problem}.`);
103
+ }
104
+ });
105
+ }
93
106
  if (Array.isArray(candidate.expectedFiles) && candidate.expectedFiles.length === 0) {
94
107
  errors.push(`${label}: answerKey.expectedFiles must not be empty.`);
95
108
  }