nx 23.3.0-beta.0 → 23.3.0-pr.36841.f66e88b

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 (116) hide show
  1. package/dist/src/command-line/affected/affected.js +41 -2
  2. package/dist/src/command-line/format/format.js +1 -10
  3. package/dist/src/command-line/graph/graph.js +37 -3
  4. package/dist/src/command-line/migrate/agentic/format-command.d.ts +14 -0
  5. package/dist/src/command-line/migrate/agentic/format-command.js +44 -0
  6. package/dist/src/command-line/migrate/agentic/handoff.d.ts +26 -15
  7. package/dist/src/command-line/migrate/agentic/handoff.js +100 -20
  8. package/dist/src/command-line/migrate/agentic/prompts/fragments.d.ts +16 -0
  9. package/dist/src/command-line/migrate/agentic/prompts/fragments.js +70 -0
  10. package/dist/src/command-line/migrate/agentic/prompts/generic-validation.js +4 -3
  11. package/dist/src/command-line/migrate/agentic/prompts/system-prompt.d.ts +9 -1
  12. package/dist/src/command-line/migrate/agentic/prompts/system-prompt.js +12 -31
  13. package/dist/src/command-line/migrate/agentic/run-step.js +11 -1
  14. package/dist/src/command-line/migrate/agentic/runner.d.ts +2 -0
  15. package/dist/src/command-line/migrate/agentic/runner.js +6 -6
  16. package/dist/src/command-line/migrate/agentic/types.d.ts +4 -6
  17. package/dist/src/command-line/migrate/agentic/types.js +4 -6
  18. package/dist/src/command-line/migrate/migrate-commits.d.ts +1 -0
  19. package/dist/src/command-line/migrate/migrate-commits.js +9 -0
  20. package/dist/src/command-line/migrate/migrate-output.d.ts +3 -7
  21. package/dist/src/command-line/migrate/migrate-output.js +3 -7
  22. package/dist/src/command-line/migrate/migrate.js +19 -6
  23. package/dist/src/command-line/migrate/run/agent-output.d.ts +10 -0
  24. package/dist/src/command-line/migrate/run/agent-output.js +16 -2
  25. package/dist/src/command-line/migrate/run/agent-work-payload.d.ts +37 -0
  26. package/dist/src/command-line/migrate/run/agent-work-payload.js +142 -0
  27. package/dist/src/command-line/migrate/run/atomic-write.d.ts +8 -0
  28. package/dist/src/command-line/migrate/run/atomic-write.js +17 -0
  29. package/dist/src/command-line/migrate/run/index.d.ts +1 -1
  30. package/dist/src/command-line/migrate/run/issues.d.ts +106 -0
  31. package/dist/src/command-line/migrate/run/issues.js +980 -0
  32. package/dist/src/command-line/migrate/run/orchestrator.d.ts +1 -0
  33. package/dist/src/command-line/migrate/run/orchestrator.js +662 -105
  34. package/dist/src/command-line/migrate/run/run-state.d.ts +43 -8
  35. package/dist/src/command-line/migrate/run/run-state.js +184 -20
  36. package/dist/src/command-line/migrate/run/runbook.d.ts +17 -0
  37. package/dist/src/command-line/migrate/run/runbook.js +97 -0
  38. package/dist/src/command-line/migrate/run/state-lock.d.ts +2 -2
  39. package/dist/src/command-line/migrate/run/state-lock.js +2 -2
  40. package/dist/src/command-line/migrate/run/state-machine.d.ts +5 -1
  41. package/dist/src/command-line/migrate/run/state-machine.js +27 -1
  42. package/dist/src/command-line/migrate/run/worker.js +206 -56
  43. package/dist/src/command-line/release/utils/get-touched-projects-for-group.js +5 -2
  44. package/dist/src/command-line/show/projects.js +25 -1
  45. package/dist/src/command-line/show/show-target/info.d.ts +20 -1
  46. package/dist/src/command-line/show/show-target/info.js +344 -7
  47. package/dist/src/command-line/show/show-target/inputs.js +26 -2
  48. package/dist/src/command-line/show/show-target/outputs.js +4 -2
  49. package/dist/src/config/task-graph.d.ts +1 -1
  50. package/dist/src/config/workspace-json-project-json.d.ts +27 -1
  51. package/dist/src/daemon/client/client.d.ts +3 -1
  52. package/dist/src/daemon/client/client.js +5 -1
  53. package/dist/src/daemon/server/handle-hash-tasks.d.ts +3 -0
  54. package/dist/src/daemon/server/handle-hash-tasks.js +8 -1
  55. package/dist/src/hasher/check-task-files.d.ts +23 -2
  56. package/dist/src/hasher/check-task-files.js +87 -3
  57. package/dist/src/hasher/create-task-hasher.d.ts +8 -1
  58. package/dist/src/hasher/create-task-hasher.js +8 -3
  59. package/dist/src/hasher/hash-plan-inspector.d.ts +27 -4
  60. package/dist/src/hasher/hash-plan-inspector.js +64 -6
  61. package/dist/src/hasher/hash-task.d.ts +2 -2
  62. package/dist/src/hasher/hash-task.js +13 -3
  63. package/dist/src/hasher/native-task-hasher-impl.d.ts +8 -4
  64. package/dist/src/hasher/native-task-hasher-impl.js +29 -7
  65. package/dist/src/hasher/task-hasher.d.ts +19 -9
  66. package/dist/src/hasher/task-hasher.js +18 -14
  67. package/dist/src/hasher/task-planning-context.d.ts +28 -0
  68. package/dist/src/hasher/task-planning-context.js +12 -0
  69. package/dist/src/io-snapshots/fetch.d.ts +29 -0
  70. package/dist/src/io-snapshots/fetch.js +107 -0
  71. package/dist/src/io-snapshots/outputs.d.ts +19 -0
  72. package/dist/src/io-snapshots/outputs.js +39 -0
  73. package/dist/src/io-snapshots/overrides.d.ts +34 -0
  74. package/dist/src/io-snapshots/overrides.js +93 -0
  75. package/dist/src/io-snapshots/report.d.ts +25 -0
  76. package/dist/src/io-snapshots/report.js +100 -0
  77. package/dist/src/native/index.d.ts +256 -5
  78. package/dist/src/native/native-bindings.js +11 -0
  79. package/dist/src/native/nx.wasm32-wasi.debug.wasm +0 -0
  80. package/dist/src/native/nx.wasm32-wasi.wasm +0 -0
  81. package/dist/src/project-graph/affected/affected-project-graph.js +5 -18
  82. package/dist/src/project-graph/affected/affected-projects.d.ts +17 -0
  83. package/dist/src/project-graph/affected/affected-projects.js +44 -0
  84. package/dist/src/project-graph/affected/affected-tasks.d.ts +36 -0
  85. package/dist/src/project-graph/affected/affected-tasks.js +225 -0
  86. package/dist/src/project-graph/affected/declared-project-references.d.ts +23 -0
  87. package/dist/src/project-graph/affected/declared-project-references.js +76 -0
  88. package/dist/src/project-graph/affected/granularity.d.ts +11 -0
  89. package/dist/src/project-graph/affected/granularity.js +15 -0
  90. package/dist/src/project-graph/affected/marshal-graph.d.ts +3 -0
  91. package/dist/src/project-graph/affected/marshal-graph.js +19 -0
  92. package/dist/src/project-graph/utils/project-configuration/target-merging.js +64 -0
  93. package/dist/src/project-graph/utils/project-configuration/target-normalization.d.ts +3 -0
  94. package/dist/src/project-graph/utils/project-configuration/target-normalization.js +59 -0
  95. package/dist/src/project-graph/utils/project-configuration/utils.d.ts +1 -0
  96. package/dist/src/project-graph/utils/project-configuration/utils.js +1 -0
  97. package/dist/src/tasks-runner/create-task-graph.js +1 -0
  98. package/dist/src/tasks-runner/prune-task-graph.d.ts +10 -0
  99. package/dist/src/tasks-runner/prune-task-graph.js +51 -0
  100. package/dist/src/tasks-runner/run-command.d.ts +14 -3
  101. package/dist/src/tasks-runner/run-command.js +57 -12
  102. package/dist/src/tasks-runner/task-io-service.d.ts +15 -0
  103. package/dist/src/tasks-runner/task-io-service.js +24 -0
  104. package/dist/src/tasks-runner/task-orchestrator.js +7 -0
  105. package/dist/src/tasks-runner/tasks-runner.d.ts +2 -0
  106. package/dist/src/utils/formatters/index.d.ts +6 -0
  107. package/dist/src/utils/formatters/index.js +5 -0
  108. package/package.json +11 -11
  109. package/schemas/nx-schema.json +38 -0
  110. package/schemas/project-schema.json +35 -0
  111. package/dist/src/project-graph/affected/locators/project-glob-changes.d.ts +0 -2
  112. package/dist/src/project-graph/affected/locators/project-glob-changes.js +0 -47
  113. package/dist/src/project-graph/affected/locators/workspace-json-changes.d.ts +0 -4
  114. package/dist/src/project-graph/affected/locators/workspace-json-changes.js +0 -47
  115. package/dist/src/project-graph/affected/locators/workspace-projects.d.ts +0 -3
  116. package/dist/src/project-graph/affected/locators/workspace-projects.js +0 -74
@@ -14,6 +14,8 @@ const affected_project_graph_1 = require("../../project-graph/affected/affected-
14
14
  const configuration_1 = require("../../config/configuration");
15
15
  const find_matching_projects_1 = require("../../utils/find-matching-projects");
16
16
  const graph_1 = require("../graph/graph");
17
+ const affected_tasks_1 = require("../../project-graph/affected/affected-tasks");
18
+ const granularity_1 = require("../../project-graph/affected/granularity");
17
19
  async function affected(command, args, extraTargetDependencies = {}, extraOptions = {
18
20
  excludeTaskDependencies: args.excludeTaskDependencies,
19
21
  loadDotEnvFiles: process.env.NX_LOAD_DOT_ENV_FILES !== 'false',
@@ -28,7 +30,44 @@ async function affected(command, args, extraTargetDependencies = {}, extraOption
28
30
  const projectGraph = await (0, project_graph_1.createProjectGraphAsync)({
29
31
  exitOnError: true,
30
32
  });
31
- const projects = await getAffectedGraphNodes(nxArgs, projectGraph);
33
+ const granularity = (0, granularity_1.resolveAffectedGranularity)();
34
+ // Task granularity needs a target to select against, so `nx graph --affected`
35
+ // and the deprecated print-affected stay project-grained.
36
+ const useTasks = granularity === 'task' &&
37
+ command === 'affected' &&
38
+ !!nxArgs.targets?.length;
39
+ let taskSelection;
40
+ let projects;
41
+ if (useTasks) {
42
+ const affectedTasks = await (0, affected_tasks_1.computeAffectedTasks)({
43
+ projectGraph,
44
+ nxJson,
45
+ targets: nxArgs.targets,
46
+ touchedFiles: (0, file_utils_1.calculateFileChanges)((0, command_line_utils_1.parseFiles)(nxArgs).files, nxArgs),
47
+ configuration: nxArgs.configuration,
48
+ overrides,
49
+ extraTargetDependencies,
50
+ excludeTaskDependencies: extraOptions.excludeTaskDependencies,
51
+ });
52
+ taskSelection = {
53
+ taskIds: [...affectedTasks.affectedTaskIds],
54
+ planningContext: affectedTasks.planningContext,
55
+ };
56
+ // --exclude is honoured in getAffectedGraphNodes, which this branch does
57
+ // not call. Dropping it would restart a project someone deliberately took
58
+ // out of the pipeline.
59
+ if (nxArgs.exclude?.length) {
60
+ const excluded = new Set((0, find_matching_projects_1.findMatchingProjects)(nxArgs.exclude, projectGraph.nodes));
61
+ taskSelection.taskIds = taskSelection.taskIds.filter((id) => !excluded.has(affectedTasks.taskGraph.tasks[id].target.project));
62
+ }
63
+ // runCommand still seeds the graph from projects; the prune is what narrows
64
+ // it back down to the selected tasks and their dependencies.
65
+ const owning = new Set(taskSelection.taskIds.map((id) => affectedTasks.taskGraph.tasks[id].target.project));
66
+ projects = [...owning].map((name) => projectGraph.nodes[name]);
67
+ }
68
+ else {
69
+ projects = await getAffectedGraphNodes(nxArgs, projectGraph);
70
+ }
32
71
  try {
33
72
  switch (command) {
34
73
  case 'affected': {
@@ -48,7 +87,7 @@ async function affected(command, args, extraTargetDependencies = {}, extraOption
48
87
  }, projectNames);
49
88
  }
50
89
  else {
51
- const status = await (0, run_command_1.runCommand)(projectsWithTarget, projectGraph, { nxJson }, nxArgs, overrides, null, extraTargetDependencies, extraOptions);
90
+ const status = await (0, run_command_1.runCommand)(projectsWithTarget, projectGraph, { nxJson }, nxArgs, overrides, null, extraTargetDependencies, extraOptions, taskSelection);
52
91
  await output_1.output.drain();
53
92
  process.exit(status);
54
93
  }
@@ -18,15 +18,6 @@ const ignore_1 = require("../../utils/ignore");
18
18
  const object_sort_1 = require("../../utils/object-sort");
19
19
  const output_1 = require("../../utils/output");
20
20
  const workspace_root_1 = require("../../utils/workspace-root");
21
- /**
22
- * A table, not a `switch`: this lookup sits inside a `try` whose `catch`
23
- * reports "configured but not installed", and a `never` arm would throw into
24
- * that catch and be misreported. A missing member is a compile error here.
25
- */
26
- const resolveFormatterBin = {
27
- oxfmt: oxfmt_1.getOxfmtBinPath,
28
- prettier: prettier_1.getPrettierPath,
29
- };
30
21
  async function format(command, args) {
31
22
  const formatterType = (0, formatters_1.detectFormatter)(workspace_root_1.workspaceRoot);
32
23
  if (!formatterType) {
@@ -41,7 +32,7 @@ async function format(command, args) {
41
32
  // node_modules. Resolving now turns a raw MODULE_NOT_FOUND into something
42
33
  // actionable.
43
34
  try {
44
- resolveFormatterBin[formatterType]();
35
+ formatters_1.resolveFormatterBin[formatterType]();
45
36
  }
46
37
  catch {
47
38
  output_1.output.error({
@@ -25,6 +25,7 @@ const create_task_graph_1 = require("../../tasks-runner/create-task-graph");
25
25
  const all_file_data_1 = require("../../utils/all-file-data");
26
26
  const command_line_utils_1 = require("../../utils/command-line-utils");
27
27
  const native_1 = require("../../native");
28
+ const overrides_1 = require("../../io-snapshots/overrides");
28
29
  const transform_objects_1 = require("../../native/transform-objects");
29
30
  const affected_1 = require("../affected/affected");
30
31
  const nx_deps_cache_1 = require("../../project-graph/nx-deps-cache");
@@ -729,7 +730,9 @@ async function createTaskGraphClientResponse(pruneExternal = false) {
729
730
  const planner = new native_1.HashPlanner(nxJson, (0, native_1.transferProjectGraph)((0, transform_objects_1.transformProjectGraphForRust)(graph)));
730
731
  node_perf_hooks_1.performance.mark('task hash plan generation:start');
731
732
  const taskIds = Object.keys(taskGraph.tasks);
732
- const plans = taskIds.length > 0 ? planner.getPlans(taskIds, taskGraph) : {};
733
+ const plans = taskIds.length > 0
734
+ ? getPlansWithIoSnapshots(planner, graph, nxJson, taskIds, taskGraph)
735
+ : {};
733
736
  node_perf_hooks_1.performance.mark('task hash plan generation:end');
734
737
  node_perf_hooks_1.performance.measure('task graph generation', 'task graph generation:start', 'task graph generation:end');
735
738
  node_perf_hooks_1.performance.measure('task hash plan generation', 'task hash plan generation:start', 'task hash plan generation:end');
@@ -807,7 +810,9 @@ async function createTaskGraphForTargetsAndProjects(targetNames, projectNames, c
807
810
  const planner = new native_1.HashPlanner(nxJson, (0, native_1.transferProjectGraph)((0, transform_objects_1.transformProjectGraphForRust)(graph)));
808
811
  node_perf_hooks_1.performance.mark('task hash plan generation:start');
809
812
  const taskIds = Object.keys(taskGraph.tasks);
810
- const plans = taskIds.length > 0 ? planner.getPlans(taskIds, taskGraph) : {};
813
+ const plans = taskIds.length > 0
814
+ ? getPlansWithIoSnapshots(planner, graph, nxJson, taskIds, taskGraph)
815
+ : {};
811
816
  node_perf_hooks_1.performance.mark('task hash plan generation:end');
812
817
  node_perf_hooks_1.performance.measure(`task graph generation for ${targetNames.join(', ')}`, `task graph generation:start`, `task graph generation:end`);
813
818
  node_perf_hooks_1.performance.measure('task hash plan generation', 'task hash plan generation:start', 'task hash plan generation:end');
@@ -850,19 +855,40 @@ async function getExpandedTaskInputs(depGraphClientResponse, expandedTaskInputsC
850
855
  expandedTaskInputsCache.set(taskId, result);
851
856
  return result;
852
857
  }
858
+ /** Plans with the HEAD bundle (never fetching); custom-hasher tasks are excluded like at hash time. */
859
+ function getPlansWithIoSnapshots(planner, graph, nxJson, taskIds, taskGraph) {
860
+ const snapshots = (0, overrides_1.loadIoSnapshotsForHead)(nxJson) ?? undefined;
861
+ return planner.getPlans(taskIds, taskGraph, snapshots, snapshots ? (0, overrides_1.customHasherTaskIds)(graph, taskGraph) : undefined);
862
+ }
853
863
  function expandInputs(inputs, project, allWorkspaceFiles, depGraphClientResponse) {
854
864
  const projectNames = depGraphClientResponse.projects.map((p) => p.name);
855
865
  const workspaceRootInputs = [];
856
866
  const projectRootInputs = [];
857
867
  const externalInputs = [];
858
868
  const otherInputs = [];
869
+ const filesInputs = [];
870
+ let snapshotBacked = false;
859
871
  inputs.forEach((input) => {
872
+ // Domain markers are not inputs, but they mean the `files` groups below
873
+ // are observed reads rather than user-declared inputs.
874
+ if (input.startsWith('io-snapshot:')) {
875
+ snapshotBacked = true;
876
+ return;
877
+ }
860
878
  // grouped workspace inputs look like workspace:[pattern,otherPattern]
861
879
  if (input.startsWith('workspace:[')) {
862
880
  const inputs = input.substring(11, input.length - 1).split(',');
863
881
  workspaceRootInputs.push(...inputs);
864
882
  return;
865
883
  }
884
+ // Disk-backed groups look like files:{project}:[glob,!otherGlob]. They
885
+ // expand on disk, so they must be matched before the `:` catch-all below
886
+ // classifies them as external dependencies.
887
+ const diskBacked = /^files:.*?:\[(.*)\]$/.exec(input);
888
+ if (diskBacked) {
889
+ filesInputs.push(diskBacked[1].split(','));
890
+ return;
891
+ }
866
892
  const maybeProjectName = input.split(':')[0];
867
893
  if (projectNames.includes(maybeProjectName)) {
868
894
  projectRootInputs.push(input);
@@ -881,6 +907,7 @@ function expandInputs(inputs, project, allWorkspaceFiles, depGraphClientResponse
881
907
  }
882
908
  });
883
909
  const workspaceRootsExpanded = getExpandedWorkspaceRoots(workspaceRootInputs, allWorkspaceFiles);
910
+ const filesExpanded = filesInputs.flatMap((globs) => (0, native_1.expandFilesInput)(workspace_root_1.workspaceRoot, globs));
884
911
  const otherInputsExpanded = otherInputs.map((input) => {
885
912
  if (input === 'TsConfig') {
886
913
  return (0, node_path_1.relative)(workspace_root_1.workspaceRoot, (0, typescript_1.getRootTsConfigPath)());
@@ -908,7 +935,14 @@ function expandInputs(inputs, project, allWorkspaceFiles, depGraphClientResponse
908
935
  return acc;
909
936
  }, {});
910
937
  return {
911
- general: [...workspaceRootsExpanded, ...otherInputsExpanded],
938
+ general: [
939
+ ...workspaceRootsExpanded,
940
+ ...filesExpanded,
941
+ ...otherInputsExpanded,
942
+ ],
943
+ // The published panel only renders `general`/`external`, so observed
944
+ // files stay in `general` and are additionally labelled here.
945
+ ...(snapshotBacked ? { observed: filesExpanded } : {}),
912
946
  ...projectRootsExpanded,
913
947
  external: externalInputs,
914
948
  };
@@ -0,0 +1,14 @@
1
+ import { FormatterType } from '../../../utils/formatters';
2
+ /**
3
+ * The command the agent runs over the files it changed, with a `<paths>`
4
+ * placeholder. `--` keeps a path that starts with `-` from being read as an
5
+ * option.
6
+ */
7
+ export declare function formatCommandFor(formatter: FormatterType, pmExec: string): string;
8
+ /**
9
+ * Resolved per dispense so a migration's formatter change takes effect; the
10
+ * scope rules name the replacement commands for the step that made it. `null`
11
+ * when no formatter is configured or its binary cannot be resolved: under npm
12
+ * the command would otherwise make `npx` download it.
13
+ */
14
+ export declare function resolveFormatCommand(root: string, pmExec: string): string | null;
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatCommandFor = formatCommandFor;
4
+ exports.resolveFormatCommand = resolveFormatCommand;
5
+ const formatters_1 = require("../../../utils/formatters");
6
+ /**
7
+ * The command the agent runs over the files it changed, with a `<paths>`
8
+ * placeholder. `--` keeps a path that starts with `-` from being read as an
9
+ * option.
10
+ */
11
+ function formatCommandFor(formatter, pmExec) {
12
+ switch (formatter) {
13
+ case 'prettier':
14
+ // --ignore-unknown skips files prettier has no parser for.
15
+ return `${pmExec} prettier --write --ignore-unknown -- <paths>`;
16
+ case 'oxfmt':
17
+ // oxfmt exits 2 when every path was skipped; the flag makes that
18
+ // success. Unlike prettier it skips unparseable files on its own.
19
+ return `${pmExec} oxfmt --no-error-on-unmatched-pattern -- <paths>`;
20
+ default: {
21
+ const unhandled = formatter;
22
+ throw new Error(`Unhandled formatter: ${unhandled}`);
23
+ }
24
+ }
25
+ }
26
+ /**
27
+ * Resolved per dispense so a migration's formatter change takes effect; the
28
+ * scope rules name the replacement commands for the step that made it. `null`
29
+ * when no formatter is configured or its binary cannot be resolved: under npm
30
+ * the command would otherwise make `npx` download it.
31
+ */
32
+ function resolveFormatCommand(root, pmExec) {
33
+ const formatter = (0, formatters_1.detectFormatter)(root);
34
+ if (formatter === null) {
35
+ return null;
36
+ }
37
+ try {
38
+ formatters_1.resolveFormatterBin[formatter]();
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ return formatCommandFor(formatter, pmExec);
44
+ }
@@ -1,3 +1,4 @@
1
+ import { type BigIntStats } from 'fs';
1
2
  import { HandoffFile } from './types';
2
3
  /** Returns the run directory for a given workspace + run id (target version). */
3
4
  export declare function runDirPath(workspaceRoot: string, runId: string): string;
@@ -20,18 +21,16 @@ export declare function mkdirSafely(dir: string, purpose: string): void;
20
21
  */
21
22
  export declare function initRunDir(workspaceRoot: string, runId: string): string;
22
23
  /**
23
- * Absolute path of the handoff file for a migration step within a run, under
24
- * the run directory's `handoffs/` subtree: the pre-authorized write scope
25
- * stops there, so a handoff placed anywhere else costs an approval prompt.
26
- * The package's scope (if any) becomes a real subdirectory so the package name
27
- * stays readable; two packages can ship a migration with the same name without
28
- * colliding because they land in different package subdirectories. Each
29
- * segment is sanitized so the path is always writable on every platform.
24
+ * Sanitizing folds many characters to `_`, so distinct migrations can share a
25
+ * prefix. The SHA-256 of the raw package and name is what keeps their handoffs
26
+ * collision-resistant.
30
27
  */
31
28
  export declare function stepHandoffPath(runDir: string, migration: {
32
29
  package: string;
33
30
  name: string;
34
31
  }): string;
32
+ /** Handoff path for a run step. No hash needed: step ids are unique within the run. */
33
+ export declare function runStepHandoffPath(runDir: string, stepId: string): string;
35
34
  export type HandoffReadFailureReason = 'missing' | 'read-error' | 'parse-error' | 'shape-mismatch';
36
35
  export type HandoffReadResult = {
37
36
  ok: true;
@@ -42,19 +41,31 @@ export type HandoffReadResult = {
42
41
  detail?: string;
43
42
  };
44
43
  /**
45
- * Reads and validates a handoff file written by an agent. Returns a tagged
46
- * result so callers (the in-loop poller and the post-exit resolver) can
47
- * distinguish "file not yet written" from "file written but garbage" — the
48
- * latter is surfaced to the user instead of being collapsed into the same
49
- * generic ambiguous-outcome prompt.
44
+ * `lstat`, not `stat`: a symlink in the handoffs dir's place would send every
45
+ * handoff read and removal wherever it points.
50
46
  */
51
- export declare function readHandoffWithReason(filePath: string): HandoffReadResult;
47
+ export declare function handoffsDirState(handoffsDir: string): 'directory' | 'missing' | 'other';
48
+ /**
49
+ * Reads the file `stat` describes, refusing a symlink swapped in after the
50
+ * caller's lstat: O_NOFOLLOW fails the open with ELOOP, and O_NONBLOCK keeps a
51
+ * planted FIFO from blocking it. Windows has neither flag, so there the inode
52
+ * comparison is what catches a followed symlink. It does not guard against an
53
+ * unlink and recreate reusing the inode number. Read errors propagate: a file
54
+ * the agent cannot read must not pass.
55
+ */
56
+ export declare function readInspectedFile(filePath: string, stat: BigIntStats, replacedMessage: string): string;
57
+ /**
58
+ * Splits "not written yet" from "written but garbage" so callers can surface a
59
+ * malformed handoff instead of collapsing it into the generic
60
+ * ambiguous-outcome prompt.
61
+ */
62
+ export declare function readHandoffWithReason(filePath: string, handoffsDir: string): HandoffReadResult;
52
63
  /**
53
64
  * Convenience wrapper preserving the original null-on-any-failure contract.
54
65
  * Used by the polling loop (`waitForValidHandoff`) where every failure mode
55
66
  * is "keep waiting" — the file may be missing, mid-write, or being rewritten.
56
67
  */
57
- export declare function readHandoff(filePath: string): HandoffFile | null;
68
+ export declare function readHandoff(filePath: string, handoffsDir: string): HandoffFile | null;
58
69
  /**
59
70
  * Polls for a valid handoff file. Resolves once `readHandoff` accepts the
60
71
  * file's contents. Used to detect when the agent has finished its work so the
@@ -63,7 +74,7 @@ export declare function readHandoff(filePath: string): HandoffFile | null;
63
74
  *
64
75
  * Rejects with the abort reason when `options.signal` is aborted.
65
76
  */
66
- export declare function waitForValidHandoff(handoffFilePath: string, options?: {
77
+ export declare function waitForValidHandoff(handoffFilePath: string, handoffsDir: string, options?: {
67
78
  intervalMs?: number;
68
79
  signal?: AbortSignal;
69
80
  }): Promise<void>;
@@ -5,9 +5,13 @@ exports.resolveAgenticRunId = resolveAgenticRunId;
5
5
  exports.mkdirSafely = mkdirSafely;
6
6
  exports.initRunDir = initRunDir;
7
7
  exports.stepHandoffPath = stepHandoffPath;
8
+ exports.runStepHandoffPath = runStepHandoffPath;
9
+ exports.handoffsDirState = handoffsDirState;
10
+ exports.readInspectedFile = readInspectedFile;
8
11
  exports.readHandoffWithReason = readHandoffWithReason;
9
12
  exports.readHandoff = readHandoff;
10
13
  exports.waitForValidHandoff = waitForValidHandoff;
14
+ const crypto_1 = require("crypto");
11
15
  const fs_1 = require("fs");
12
16
  const path_1 = require("path");
13
17
  const semver_1 = require("semver");
@@ -63,7 +67,7 @@ const WINDOWS_RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
63
67
  function sanitizeSegment(value) {
64
68
  if (value === '.' || value === '..')
65
69
  return '_';
66
- let sanitized = value.replace(/[\x00-\x1f<>:"/\\|?*]/g, '_');
70
+ let sanitized = value.replace(/[\x00-\x1f<>:"/\\|?*+]/g, '_');
67
71
  // Windows forbids trailing dots/spaces on file/directory names.
68
72
  sanitized = sanitized.replace(/[. ]+$/, '');
69
73
  if (WINDOWS_RESERVED_NAMES.test(sanitized)) {
@@ -71,29 +75,105 @@ function sanitizeSegment(value) {
71
75
  }
72
76
  return sanitized || '_';
73
77
  }
78
+ const HANDOFF_NAME_PREFIX_MAX_BYTES = 64;
79
+ // Cuts on code points so a multibyte character is never split, and counts
80
+ // UTF-8 bytes because the filesystem limit is per byte, not per character.
81
+ function truncateUtf8(value, maxBytes) {
82
+ let out = '';
83
+ let used = 0;
84
+ for (const ch of value) {
85
+ const bytes = Buffer.byteLength(ch);
86
+ if (used + bytes > maxBytes)
87
+ break;
88
+ out += ch;
89
+ used += bytes;
90
+ }
91
+ return out;
92
+ }
74
93
  /**
75
- * Absolute path of the handoff file for a migration step within a run, under
76
- * the run directory's `handoffs/` subtree: the pre-authorized write scope
77
- * stops there, so a handoff placed anywhere else costs an approval prompt.
78
- * The package's scope (if any) becomes a real subdirectory so the package name
79
- * stays readable; two packages can ship a migration with the same name without
80
- * colliding because they land in different package subdirectories. Each
81
- * segment is sanitized so the path is always writable on every platform.
94
+ * Sanitizing folds many characters to `_`, so distinct migrations can share a
95
+ * prefix. The SHA-256 of the raw package and name is what keeps their handoffs
96
+ * collision-resistant.
82
97
  */
83
98
  function stepHandoffPath(runDir, migration) {
84
- return (0, path_1.join)(runDir, types_1.HANDOFFS_DIR_NAME, ...migration.package.split('/').map(sanitizeSegment), `${sanitizeSegment(migration.name)}.json`);
99
+ const prefix = truncateUtf8([...migration.package.split('/'), migration.name]
100
+ .map(sanitizeSegment)
101
+ .join('+'), HANDOFF_NAME_PREFIX_MAX_BYTES);
102
+ const hash = (0, crypto_1.createHash)('sha256')
103
+ .update(JSON.stringify([migration.package, migration.name]))
104
+ .digest('hex');
105
+ return (0, path_1.join)(runDir, types_1.HANDOFFS_DIR_NAME, `${prefix}-${hash}.json`);
106
+ }
107
+ /** Handoff path for a run step. No hash needed: step ids are unique within the run. */
108
+ function runStepHandoffPath(runDir, stepId) {
109
+ return (0, path_1.join)(runDir, types_1.HANDOFFS_DIR_NAME, `${sanitizeSegment(stepId)}.json`);
110
+ }
111
+ /**
112
+ * `lstat`, not `stat`: a symlink in the handoffs dir's place would send every
113
+ * handoff read and removal wherever it points.
114
+ */
115
+ function handoffsDirState(handoffsDir) {
116
+ try {
117
+ return (0, fs_1.lstatSync)(handoffsDir).isDirectory() ? 'directory' : 'other';
118
+ }
119
+ catch (err) {
120
+ if (err?.code === 'ENOENT')
121
+ return 'missing';
122
+ throw err;
123
+ }
85
124
  }
86
125
  /**
87
- * Reads and validates a handoff file written by an agent. Returns a tagged
88
- * result so callers (the in-loop poller and the post-exit resolver) can
89
- * distinguish "file not yet written" from "file written but garbage" — the
90
- * latter is surfaced to the user instead of being collapsed into the same
91
- * generic ambiguous-outcome prompt.
126
+ * Reads the file `stat` describes, refusing a symlink swapped in after the
127
+ * caller's lstat: O_NOFOLLOW fails the open with ELOOP, and O_NONBLOCK keeps a
128
+ * planted FIFO from blocking it. Windows has neither flag, so there the inode
129
+ * comparison is what catches a followed symlink. It does not guard against an
130
+ * unlink and recreate reusing the inode number. Read errors propagate: a file
131
+ * the agent cannot read must not pass.
92
132
  */
93
- function readHandoffWithReason(filePath) {
133
+ function readInspectedFile(filePath, stat, replacedMessage) {
134
+ const fd = (0, fs_1.openSync)(filePath, fs_1.constants.O_RDONLY |
135
+ (fs_1.constants.O_NOFOLLOW ?? 0) |
136
+ (fs_1.constants.O_NONBLOCK ?? 0));
137
+ try {
138
+ const fdStat = (0, fs_1.fstatSync)(fd, { bigint: true });
139
+ if (!fdStat.isFile() ||
140
+ fdStat.dev !== stat.dev ||
141
+ fdStat.ino !== stat.ino) {
142
+ throw new Error(replacedMessage);
143
+ }
144
+ return (0, fs_1.readFileSync)(fd, 'utf-8');
145
+ }
146
+ finally {
147
+ (0, fs_1.closeSync)(fd);
148
+ }
149
+ }
150
+ /**
151
+ * Splits "not written yet" from "written but garbage" so callers can surface a
152
+ * malformed handoff instead of collapsing it into the generic
153
+ * ambiguous-outcome prompt.
154
+ */
155
+ function readHandoffWithReason(filePath, handoffsDir) {
94
156
  let raw;
95
157
  try {
96
- raw = (0, fs_1.readFileSync)(filePath, 'utf-8');
158
+ switch (handoffsDirState(handoffsDir)) {
159
+ case 'missing':
160
+ return { ok: false, reason: 'missing' };
161
+ case 'other':
162
+ return {
163
+ ok: false,
164
+ reason: 'read-error',
165
+ detail: `${handoffsDir} is not a directory`,
166
+ };
167
+ }
168
+ const stat = (0, fs_1.lstatSync)(filePath, { bigint: true });
169
+ if (!stat.isFile()) {
170
+ return {
171
+ ok: false,
172
+ reason: 'read-error',
173
+ detail: `${filePath} is not a regular file`,
174
+ };
175
+ }
176
+ raw = readInspectedFile(filePath, stat, `${filePath} was replaced while being read`);
97
177
  }
98
178
  catch (err) {
99
179
  const code = err?.code;
@@ -148,8 +228,8 @@ function readHandoffWithReason(filePath) {
148
228
  * Used by the polling loop (`waitForValidHandoff`) where every failure mode
149
229
  * is "keep waiting" — the file may be missing, mid-write, or being rewritten.
150
230
  */
151
- function readHandoff(filePath) {
152
- const result = readHandoffWithReason(filePath);
231
+ function readHandoff(filePath, handoffsDir) {
232
+ const result = readHandoffWithReason(filePath, handoffsDir);
153
233
  return result.ok ? result.handoff : null;
154
234
  }
155
235
  /**
@@ -160,7 +240,7 @@ function readHandoff(filePath) {
160
240
  *
161
241
  * Rejects with the abort reason when `options.signal` is aborted.
162
242
  */
163
- function waitForValidHandoff(handoffFilePath, options = {}) {
243
+ function waitForValidHandoff(handoffFilePath, handoffsDir, options = {}) {
164
244
  const intervalMs = options.intervalMs ?? 500;
165
245
  const { signal } = options;
166
246
  return new Promise((resolve, reject) => {
@@ -180,7 +260,7 @@ function waitForValidHandoff(handoffFilePath, options = {}) {
180
260
  onAbort();
181
261
  return;
182
262
  }
183
- if (readHandoff(handoffFilePath) !== null) {
263
+ if (readHandoff(handoffFilePath, handoffsDir) !== null) {
184
264
  signal?.removeEventListener('abort', onAbort);
185
265
  resolve();
186
266
  return;
@@ -0,0 +1,16 @@
1
+ export declare function renderNxInvocationNote(packageManager: string, nxInvocation: string): string;
2
+ export declare function renderHandoffShapeInline(summaryHint: string): string;
3
+ export declare function renderHandoffShapeLines(): string[];
4
+ /**
5
+ * Where the formatter command comes from. A prompt built right before the
6
+ * agent runs carries the resolved command; the runbook outlives many steps,
7
+ * so it points at the `Format command:` line each dispensed step resolves.
8
+ */
9
+ export type FormatInstruction = {
10
+ source: 'command';
11
+ command: string | null;
12
+ } | {
13
+ source: 'dispensed-step';
14
+ };
15
+ export declare function renderAuthorScopeRuleLines(pmExec: string, format: FormatInstruction): string[];
16
+ export declare function renderValidationScopeRuleLines(): string[];
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ // Wording the agent system prompt (system-prompt.ts) and the orchestrated
3
+ // run's runbook (run/runbook.ts) must state identically.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.renderNxInvocationNote = renderNxInvocationNote;
6
+ exports.renderHandoffShapeInline = renderHandoffShapeInline;
7
+ exports.renderHandoffShapeLines = renderHandoffShapeLines;
8
+ exports.renderAuthorScopeRuleLines = renderAuthorScopeRuleLines;
9
+ exports.renderValidationScopeRuleLines = renderValidationScopeRuleLines;
10
+ const format_command_1 = require("../format-command");
11
+ function renderNxInvocationNote(packageManager, nxInvocation) {
12
+ return `Use \`${packageManager}\` for any package-manager invocation in this workspace. To invoke nx, use \`${nxInvocation} ...\`. Do not default to a different package manager based on your own preference.`;
13
+ }
14
+ // One-line form for a dispensed step; the block form is the contract itself.
15
+ function renderHandoffShapeInline(summaryHint) {
16
+ return `{ "status": "success" | "failed", "summary": "<${summaryHint}>" }`;
17
+ }
18
+ function renderHandoffShapeLines() {
19
+ return [
20
+ `{`,
21
+ ` "status": "success" | "failed",`,
22
+ ` "summary": "[one to three sentences: what was done, or why it failed]"`,
23
+ `}`,
24
+ ];
25
+ }
26
+ function renderAuthorScopeRuleLines(pmExec, format) {
27
+ return [
28
+ `- Apply only the changes the migration prompt asks for.`,
29
+ `- Do not refactor or update dependencies beyond what the migration prompt directs, and do not reformat files you did not change.`,
30
+ renderFormatRule(pmExec, format),
31
+ `- Do not modify files outside the workspace root.`,
32
+ `- Do not run \`nx\` commands that mutate workspace state (\`nx migrate\`, \`nx reset\`, \`nx format:write\`, \`nx run-many\`, generators, etc.). Read-only inspection (\`nx show\`, \`nx graph --file\`, reading files) is fine.`,
33
+ `- If the migration instructions are unclear, internally inconsistent, or conflict with the current workspace state, ask the user for direction (see the handoff contract). Do not guess.`,
34
+ ];
35
+ }
36
+ const FORMAT_SCOPE = `over exactly the files you created or modified`;
37
+ // oxfmt with no paths formats every file under cwd (its no-target default,
38
+ // with or without the flag), so the no-files case must be said explicitly.
39
+ const NO_FILES = `If you created or modified no files, do not run it.`;
40
+ const NO_NX_FORMAT = `Do not use \`nx format:write\` for this: it also selects files changed earlier on the branch and always reformats the root config files.`;
41
+ function renderFormatRule(pmExec, format) {
42
+ // The command is resolved before the step runs, so the step that adds or
43
+ // replaces the formatter is the one case it cannot cover; name the exact
44
+ // replacements so the agent does not have to guess flags.
45
+ const replacement = `If this migration itself added or replaced the workspace formatter, run the new one ${FORMAT_SCOPE} instead: \`${(0, format_command_1.formatCommandFor)('prettier', pmExec)}\` for Prettier, \`${(0, format_command_1.formatCommandFor)('oxfmt', pmExec)}\` for oxfmt, but only if it is installed under node_modules; if it is not, do not install or run it. ${NO_FILES}`;
46
+ switch (format.source) {
47
+ case 'command':
48
+ return format.command === null
49
+ ? `- No configured formatter is installed in this workspace; do not install or run one over your changes. ${replacement}`
50
+ : `- After applying your changes and before writing the handoff, run \`${format.command}\` ${FORMAT_SCOPE} (the flag keeps the command from failing when some or all of those paths are files the formatter does not handle). ${replacement} ${NO_NX_FORMAT}`;
51
+ case 'dispensed-step':
52
+ return `- After applying your changes and before writing the handoff, run the command on the dispensed step's \`Format command:\` line ${FORMAT_SCOPE}; when that line says none, do not run a formatter. ${replacement} ${NO_NX_FORMAT}`;
53
+ default: {
54
+ const unhandled = format;
55
+ throw new Error(`Unhandled format instruction: ${JSON.stringify(unhandled)}`);
56
+ }
57
+ }
58
+ }
59
+ function renderValidationScopeRuleLines() {
60
+ return [
61
+ `- Your job is to validate the generator's changes. Inspect the listed changes, run the smallest relevant set of verification tasks, and report findings.`,
62
+ `- Discover what targets exist before running tasks: \`nx show project <name> --json\` is authoritative, since it includes targets inferred by plugins. Reading a project's \`project.json\` / \`package.json\` misses inferred targets; treat it as an incomplete fallback for when the project graph cannot be built. Do not assume specific target names (\`typecheck\`, \`test\`, \`lint\`) are available, since workspaces vary. Run what the project actually has; if no typecheck-equivalent exists, \`build\` is an acceptable substitute.`,
63
+ `- You may run nx tasks for verification, scoped to this migration's changes: \`nx affected --files=<comma-separated changed paths> -t <target>\` (\`--files\` splits on every comma, so pipe paths one per line into \`nx affected --stdin -t <target>\` when a changed path contains one), \`nx run <project>:<target>\`, or \`nx run-many -t <target> -p <project1>,<project2>\` where the project list is derived from the changed files. Bare \`nx affected\` (no \`--files\`) selects the branch delta plus everything uncommitted, and unscoped \`nx run-many\` (no \`-p\`) is forbidden.`,
64
+ `- Read-only and artifact-writing inspection commands are permitted: \`nx show project\`, \`nx graph --file <path>\`, reading files. These do not mutate workspace source.`,
65
+ `- You may apply minor fixes only when the issue lies within the scope of what this migration intended to accomplish (e.g. a missing import the generator's template should have produced, a type annotation the template missed). Do not refactor, do not modify unrelated functionality, do not extend the migration's scope, do not touch code the migration was not concerned with. If you are unsure whether a fix is in scope, report it in \`summary\` instead of applying.`,
66
+ `- Do not run other \`nx\` commands that mutate workspace state (\`nx migrate\`, \`nx reset\`, generators, etc.).`,
67
+ `- Do not modify files outside the workspace root.`,
68
+ `- If validation finds blocking issues you cannot resolve within scope: apply every fix you can within scope, then report the unresolved findings to the user and ask how to proceed (see the handoff contract). Do not guess.`,
69
+ ];
70
+ }
@@ -39,10 +39,11 @@ function buildGenericValidationUserPrompt(ctx) {
39
39
  if (agentContext.length > 0) {
40
40
  lines.push(...(0, shared_rendering_1.renderAdvisoryContext)('hints emitted by the generator; treat as supplementary context, not separate tasks', agentContext));
41
41
  }
42
+ const targetDiscovery = `discover each project's targets with \`nx show project <name> --json\`, which includes targets inferred by plugins (reading \`project.json\` / \`package.json\` misses those; fall back to it only if the project graph cannot be built). Do not assume \`typecheck\` / \`test\` / \`lint\` exist. If no typecheck-equivalent exists, \`build\` is an acceptable substitute.`;
42
43
  const firstStep = ctx.impl.hasDiffContext
43
- ? `1. Inspect this migration's changes. ${(0, shared_rendering_1.renderGitInspectInstruction)()} Resolve each affected path to its owning Nx project via \`nx show project <name>\` (or by reading the project's \`project.json\` / \`package.json\`) to discover which targets each project actually defines — do not assume \`typecheck\` / \`test\` / \`lint\` exist. If no typecheck-equivalent exists, \`build\` is an acceptable substitute.`
44
- : `1. Resolve each path in <files_changed> to its owning Nx project. Use \`nx show project <name>\` (or read the project's \`project.json\` / \`package.json\`) to discover which targets each project actually defines — do not assume \`typecheck\` / \`test\` / \`lint\` exist. If no typecheck-equivalent exists, \`build\` is an acceptable substitute.`;
45
- lines.push(``, `<validation_instructions>`, firstStep, `2. Pick the smallest relevant subset of available targets to verify the change. Prefer \`nx affected -t <target>\` (or \`nx run <project>:<target>\` for a single project). When many small projects are affected, you may use \`nx run-many -t <target> -p <project1>,<project2>\` with the project list derived from the changed files. Unscoped \`nx run-many\` (no \`-p\`) is forbidden.`, `3. If a verification surfaces an issue the migration should have produced cleanly (e.g. a missing import, a type annotation the generator's template missed), you may apply a minor in-scope fix. The boundary is "what this migration intended to accomplish" — do not refactor, do not modify functionality unrelated to the migration, do not extend the migration's scope, do not touch code the migration was not concerned with. If you are unsure whether a fix is in scope, report it in \`summary\` instead of applying.`, `4. Apply every fix you can within scope, then end the step per the handoff contract. If everything is resolved, write your handoff with \`status: "success"\`, summarizing what you verified and any fixes you applied. If unresolved findings remain, report them to the user and ask how to proceed before writing any handoff; on a \`status: "failed"\` handoff, enumerate the findings in \`summary\` so the user can address them — no commit will be created from a failed run, so the generator's changes and your partial fixes will sit uncommitted in the working tree for the user to review.`, `</validation_instructions>`, ``, `When you end the step per the handoff contract, your handoff path is:`, ...(0, shared_rendering_1.renderHandoffPathFooter)(ctx.handoffFileAbsolutePath));
44
+ ? `1. Inspect this migration's changes. ${(0, shared_rendering_1.renderGitInspectInstruction)()} Resolve each affected path to its owning Nx project, then ${targetDiscovery}`
45
+ : `1. Resolve each path in <files_changed> to its owning Nx project, then ${targetDiscovery}`;
46
+ lines.push(``, `<validation_instructions>`, firstStep, `2. Pick the smallest relevant subset of available targets to verify the change. Prefer \`nx affected --files=<comma-separated changed paths> -t <target>\` with the changed file list (bare \`nx affected\` also selects the branch delta and unrelated uncommitted changes). \`--files\` splits on every comma, so when a changed path contains one, pipe the paths one per line into \`nx affected --stdin -t <target>\` instead (POSIX shells: \`printf '%s\\n' <paths> | nx affected --stdin -t <target>\`). Alternatively, use \`nx run <project>:<target>\` for a single project. When many small projects are affected, you may use \`nx run-many -t <target> -p <project1>,<project2>\` with the project list derived from the changed files. Unscoped \`nx run-many\` (no \`-p\`) is forbidden.`, `3. If a verification surfaces an issue the migration should have produced cleanly (e.g. a missing import, a type annotation the generator's template missed), you may apply a minor in-scope fix. The boundary is "what this migration intended to accomplish" — do not refactor, do not modify functionality unrelated to the migration, do not extend the migration's scope, do not touch code the migration was not concerned with. If you are unsure whether a fix is in scope, report it in \`summary\` instead of applying.`, `4. Apply every fix you can within scope, then end the step per the handoff contract. If everything is resolved, write your handoff with \`status: "success"\`, summarizing what you verified and any fixes you applied. If unresolved findings remain, report them to the user and ask how to proceed before writing any handoff; on a \`status: "failed"\` handoff, enumerate the findings in \`summary\` so the user can address them — no commit will be created from a failed run, so the generator's changes and your partial fixes will sit uncommitted in the working tree for the user to review.`, `</validation_instructions>`, ``, `When you end the step per the handoff contract, your handoff path is:`, ...(0, shared_rendering_1.renderHandoffPathFooter)(ctx.handoffFileAbsolutePath));
46
47
  return lines.join('\n');
47
48
  }
48
49
  function renderFileListBody(changes) {
@@ -25,9 +25,17 @@ export interface SystemPromptContext {
25
25
  * a generator's output. Constraints allow scoped task execution and minor
26
26
  * in-scope fixes.
27
27
  *
28
- * Defaults to `author` so existing call sites remain unchanged.
28
+ * Defaults to `author`.
29
29
  */
30
30
  mode?: AgenticPromptMode;
31
+ /**
32
+ * Exact formatter command for the files the agent changed (see
33
+ * `resolveFormatCommand`), or `null` when none can be resolved. Ignored for
34
+ * `generic-validation` prompts; pass `null` there without probing, since
35
+ * formatter detection can warn.
36
+ */
37
+ formatCommand: string | null;
38
+ pmExec: string;
31
39
  }
32
40
  /**
33
41
  * Builds the agent-agnostic system prompt used for all prompt-migration steps.